import { vi } from 'vitest';
const mockConfig = {
    server: { port: 3000 },
    user: {
        gitlabUsername: 'claude-bot',
        githubUsername: 'claude-bot',
    },
    queue: { maxConcurrent: 1, deduplicationWindowMs: 60000 },
    repositories: [],
    github: {
        labelTrigger: 'claude-review',
    },
};
const mockRepoConfig = {
    name: 'test-repo',
    platform: 'github',
    localPath: '/home/user/projects/test-repo',
    remoteUrl: 'https://github.com/test-owner/test-repo.git',
    skill: 'review-front',
    enabled: true,
};
vi.mock('../../../../../config/loader.js', () => ({
    loadConfig: vi.fn(() => mockConfig),
    findRepositoryByRemoteUrl: vi.fn(() => mockRepoConfig),
}));
vi.mock('../../../../../security/verifier.js', () => ({
    verifyGitHubSignature: vi.fn(() => ({ valid: true })),
    getGitHubEventType: vi.fn(() => 'pull_request'),
}));
vi.mock('../../../../../frameworks/queue/pQueueAdapter.js', () => ({
    createJobId: vi.fn(() => 'github-test-owner/test-repo-123'),
    enqueueReview: vi.fn(() => Promise.resolve(true)),
    updateJobProgress: vi.fn(),
    cancelJob: vi.fn(),
}));
vi.mock('../../../../../claude/invoker.js', () => ({
    invokeClaudeReview: vi.fn(),
    sendNotification: vi.fn(),
}));
vi.mock('../../../../../main/websocket.js', () => ({
    startWatchingReviewContext: vi.fn(),
    stopWatchingReviewContext: vi.fn(),
}));
vi.mock('@/modules/review-execution/services/contextActionsExecutor.js', () => ({
    executeActionsFromContext: vi.fn(() => Promise.resolve({ total: 1, succeeded: 1, failed: 0, skipped: 0 })),
}));
vi.mock('@/modules/review-execution/services/threadActionsExecutor.js', () => ({
    executeThreadActions: vi.fn(() => Promise.resolve({ total: 0, succeeded: 0, failed: 0, skipped: 0 })),
    defaultCommandExecutor: vi.fn(),
}));
vi.mock('@/modules/review-execution/services/threadActionsParser.js', () => ({
    parseThreadActions: vi.fn(() => []),
}));
vi.mock('../../../../../config/projectConfig.js', () => ({
    loadProjectConfig: vi.fn(() => null),
    getProjectAgents: vi.fn(() => null),
    getProjectAgentsOrFocusDefaults: vi.fn(() => null),
    getFollowupAgents: vi.fn(() => null),
    getProjectLanguage: vi.fn(() => 'en'),
}));
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { handleGitHubWebhook } from '../../../../../modules/platform-integration/interface-adapters/controllers/webhook/github.controller.js';
import { GitHubEventFactory } from '../../../../factories/gitHubEvent.factory.js';
import { createStubLogger } from '../../../../stubs/logger.stub.js';
import { enqueueReview } from '../../../../../frameworks/queue/pQueueAdapter.js';
import { invokeClaudeReview } from '../../../../../claude/invoker.js';
import { TrackedMrFactory } from '../../../../factories/trackedMr.factory.js';
function createMockDeps() {
    return {
        reviewContextGateway: {
            create: vi.fn(),
            read: vi.fn(() => null),
            delete: vi.fn(() => ({ deleted: true })),
            updateProgress: vi.fn(),
            appendAction: vi.fn(),
        },
        threadFetchGateway: {
            fetchThreads: vi.fn(() => []),
        },
        diffMetadataFetchGateway: {
            fetchDiffMetadata: vi.fn(() => ({
                baseSha: 'abc',
                headSha: 'def',
                startSha: 'ghi',
            })),
        },
        diffStatsFetchGateway: {
            fetchDiffStats: vi.fn(() => null),
        },
        trackAssignment: { execute: vi.fn() },
        recordCompletion: { execute: vi.fn() },
        recordPush: { execute: vi.fn(() => null) },
        transitionState: { execute: vi.fn() },
        checkFollowupNeeded: { execute: vi.fn(() => false) },
        syncThreads: { execute: vi.fn(() => null) },
        enforceBudget: {
            execute: vi.fn(async () => ({
                accepted: true,
                status: {
                    limitUsd: 200,
                    consumedUsd: 0,
                    remainingUsd: 200,
                    percentUsed: 0,
                    exceeded: false,
                    periodStart: '2026-05-01T00:00:00.000Z',
                },
            })),
        },
        broadcastBudgetExceeded: vi.fn(),
        getRepositories: vi.fn(() => []),
        removeWorktree: vi.fn(async () => ({ status: 'removed' })),
        recordBypass: { execute: vi.fn(() => ({ kind: 'no-marker' })) },
        noteCommentPostGateway: { postComment: vi.fn(async () => undefined) },
        handlePlatformApproval: { execute: vi.fn(() => ({ kind: 'allowed' })) },
        approvalRevocationGateway: { revoke: vi.fn(async () => undefined) },
        getQualityThreshold: vi.fn(() => null),
        now: () => '2026-05-26T12:00:00.000Z',
    };
}
function createMockTrackingGateway() {
    const basicMr = TrackedMrFactory.create({
        id: 'github-test-owner/test-repo-123',
        mrNumber: 123,
        platform: 'github',
        project: 'test-owner/test-repo',
    });
    return {
        getById: vi.fn(() => basicMr),
        getByNumber: vi.fn(() => null),
        create: vi.fn(),
        update: vi.fn(),
        getByState: vi.fn(() => []),
        getActiveMrs: vi.fn(() => []),
        remove: vi.fn(() => true),
        archive: vi.fn(() => true),
        recordReviewEvent: vi.fn(),
        recordPush: vi.fn(() => null),
        loadTracking: vi.fn(() => null),
        saveTracking: vi.fn(),
    };
}
describe('handleGitHubWebhook', () => {
    let mockReply;
    let mockGateway;
    let mockDeps;
    const logger = createStubLogger();
    beforeEach(() => {
        vi.clearAllMocks();
        mockReply = {
            status: vi.fn().mockReturnThis(),
            send: vi.fn().mockReturnThis(),
        };
        mockGateway = createMockTrackingGateway();
        mockDeps = createMockDeps();
    });
    afterEach(() => {
        vi.resetAllMocks();
    });
    describe('PR tracking on review request', () => {
        it('should track PR assignment when review is requested', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = {
                body: event,
                headers: {},
            };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(expect.objectContaining({
                projectPath: '/home/user/projects/test-repo',
                mrInfo: expect.objectContaining({
                    mrNumber: 123,
                    title: 'Test PR',
                    url: 'https://github.com/test-owner/test-repo/pull/123',
                    project: 'test-owner/test-repo',
                    platform: 'github',
                    sourceBranch: 'feature/test',
                    targetBranch: 'main',
                }),
                assignedBy: expect.objectContaining({
                    username: 'developer',
                }),
            }));
        });
        it('should track PR assignment when labeled with needs-review', async () => {
            const event = GitHubEventFactory.createLabeledPr('needs-review');
            const request = {
                body: event,
                headers: {},
            };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(expect.objectContaining({
                mrInfo: expect.objectContaining({
                    mrNumber: 123,
                    platform: 'github',
                }),
            }));
        });
    });
    describe('review completion callback', () => {
        it('should record review stats after successful review', async () => {
            const mockResult = {
                success: true,
                cancelled: false,
                stdout: '[REVIEW_STATS:blocking=2:warnings=3:suggestions=1:score=7.5]',
                durationMs: 120000,
                exitCode: 0,
                stderr: '',
            };
            vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
                await callback(job, new AbortController().signal);
                return true;
            });
            vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.recordCompletion.execute).toHaveBeenCalledWith(expect.objectContaining({
                projectPath: '/home/user/projects/test-repo',
                mrId: 'github-test-owner/test-repo-123',
                reviewData: expect.objectContaining({
                    type: 'review',
                    score: 7.5,
                    blocking: 2,
                    warnings: 3,
                    suggestions: 1,
                }),
            }));
        });
        it('should count only blocking issues as open threads, not warnings', async () => {
            const mockResult = {
                success: true,
                cancelled: false,
                stdout: '[REVIEW_STATS:blocking=1:warnings=5:suggestions=2:score=6]',
                durationMs: 90000,
                exitCode: 0,
                stderr: '',
            };
            vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
                await callback(job, new AbortController().signal);
                return true;
            });
            vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.recordCompletion.execute).toHaveBeenCalledWith(expect.objectContaining({
                reviewData: expect.objectContaining({
                    blocking: 1,
                    threadsOpened: 1, // Only blocking count, not blocking + warnings (6)
                }),
            }));
        });
        it('finalises the review-context with setResult after actions are posted', async () => {
            const setResultMock = vi.fn((_localPath, _mergeRequestId, _result) => ({ success: true }));
            const readMock = vi.fn(() => ({
                version: '1.0',
                mergeRequestId: 'github-test-owner/test-repo-123',
                platform: 'github',
                projectPath: 'test-owner/test-repo',
                mergeRequestNumber: 123,
                createdAt: '2026-05-26T00:00:00Z',
                threads: [],
                actions: [{ type: 'POST_COMMENT', body: 'review report' }],
                progress: { phase: 'completed', currentStep: null },
            }));
            const depsWithReadAndSet = {
                ...mockDeps,
                reviewContextGateway: {
                    ...mockDeps.reviewContextGateway,
                    read: readMock,
                    setResult: setResultMock,
                },
            };
            vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
                await callback(job, new AbortController().signal);
                return true;
            });
            vi.mocked(invokeClaudeReview).mockResolvedValue({
                success: true,
                cancelled: false,
                stdout: '[REVIEW_STATS:blocking=3:warnings=1:suggestions=0:score=5]',
                durationMs: 100,
                exitCode: 0,
                stderr: '',
            });
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, depsWithReadAndSet);
            expect(setResultMock).toHaveBeenCalledTimes(1);
            const [, , passedResult] = setResultMock.mock.calls[0];
            expect(passedResult).toEqual({
                kind: 'measured',
                blocking: 3,
                warnings: 1,
                suggestions: 0,
                score: 5,
                verdict: 'needs_fixes',
            });
        });
        it('should not record stats when review is cancelled', async () => {
            const mockResult = {
                success: false,
                cancelled: true,
                stdout: '',
                durationMs: 5000,
                exitCode: 1,
                stderr: '',
            };
            vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
                await callback(job, new AbortController().signal);
                return true;
            });
            vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.recordCompletion.execute).not.toHaveBeenCalled();
        });
        it('should not record stats when review fails', async () => {
            const mockResult = {
                success: false,
                cancelled: false,
                stdout: 'Error occurred',
                durationMs: 10000,
                exitCode: 1,
                stderr: 'dispatch-failed: branch-not-found',
            };
            // Simulate pQueue behaviour: queue swallows processor throws and surfaces
            // them via jobStatus.error (see pQueueAdapter.ts catch block).
            vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
                try {
                    await callback(job, new AbortController().signal);
                }
                catch {
                    // expected: controller now throws on dispatch failure so the
                    // queue can populate jobStatus.error for dashboard surfacing
                }
                return true;
            });
            vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.recordCompletion.execute).not.toHaveBeenCalled();
        });
    });
    describe('assignedBy attribution', () => {
        it('should use PR assignee as assignedBy when assignee is present', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            event.pull_request.assignees = [{ login: 'pr-owner' }];
            event.sender = { login: 'reviewer-who-requested' };
            const request = {
                body: event,
                headers: {},
            };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(expect.objectContaining({
                assignedBy: expect.objectContaining({
                    username: 'pr-owner',
                    displayName: 'pr-owner',
                }),
            }));
        });
        it('should fallback to sender when no assignee is present', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            event.pull_request.assignees = [];
            event.sender = { login: 'webhook-sender' };
            const request = {
                body: event,
                headers: {},
            };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(expect.objectContaining({
                assignedBy: expect.objectContaining({
                    username: 'webhook-sender',
                    displayName: 'webhook-sender',
                }),
            }));
        });
        it('should use first assignee when multiple assignees exist', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            event.pull_request.assignees = [
                { login: 'primary-owner' },
                { login: 'secondary-owner' },
            ];
            event.sender = { login: 'reviewer' };
            const request = {
                body: event,
                headers: {},
            };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(expect.objectContaining({
                assignedBy: expect.objectContaining({
                    username: 'primary-owner',
                    displayName: 'primary-owner',
                }),
            }));
        });
        it('should fallback to sender when assignees field is undefined', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            event.pull_request.assignees = undefined;
            event.sender = { login: 'fallback-sender' };
            const request = {
                body: event,
                headers: {},
            };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(expect.objectContaining({
                assignedBy: expect.objectContaining({
                    username: 'fallback-sender',
                    displayName: 'fallback-sender',
                }),
            }));
        });
    });
    describe('budget cap gate', () => {
        it('rejects a fresh review and broadcasts budget-exceeded when enforceBudget returns accepted=false', async () => {
            const exceededDeps = createMockDeps();
            exceededDeps.enforceBudget.execute = vi.fn(async () => ({
                accepted: false,
                status: {
                    limitUsd: 200,
                    consumedUsd: 200.1,
                    remainingUsd: 0,
                    percentUsed: 100.05,
                    exceeded: true,
                    periodStart: '2026-05-01T00:00:00.000Z',
                },
            }));
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, exceededDeps);
            expect(exceededDeps.enforceBudget.execute).toHaveBeenCalled();
            expect(enqueueReview).not.toHaveBeenCalled();
            expect(exceededDeps.broadcastBudgetExceeded).toHaveBeenCalledWith(expect.objectContaining({
                mrNumber: 123,
                platform: 'github',
                projectPath: 'test-owner/test-repo',
                limitUsd: 200,
                consumedUsd: 200.1,
            }));
            expect(mockReply.status).toHaveBeenCalledWith(200);
            expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'rejected', reason: 'budget-exceeded' }));
        });
        it('enqueues a fresh review when enforceBudget returns accepted=true', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            const acceptedDeps = mockDeps;
            expect(acceptedDeps.enforceBudget.execute).toHaveBeenCalled();
            expect(enqueueReview).toHaveBeenCalled();
            expect(acceptedDeps.broadcastBudgetExceeded).not.toHaveBeenCalled();
        });
    });
    describe('followup branch on synchronize event', () => {
        function buildFollowupDeps() {
            const deps = createMockDeps();
            const trackedMr = TrackedMrFactory.create({
                id: 'github-test-owner/test-repo-123',
                mrNumber: 123,
                platform: 'github',
                project: 'test-owner/test-repo',
                state: 'pending-fix',
                openThreads: 3,
                totalThreads: 3,
                lastPushAt: '2026-05-20T12:00:00Z',
                lastReviewAt: '2026-05-20T10:00:00Z',
                autoFollowup: true,
            });
            deps.recordPush.execute.mockReturnValue(trackedMr);
            deps.checkFollowupNeeded.execute.mockReturnValue(true);
            return deps;
        }
        it('records push, checks followup, and enqueues followup job on synchronize', async () => {
            const deps = buildFollowupDeps();
            const event = GitHubEventFactory.createSynchronizePr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(deps.recordPush.execute).toHaveBeenCalledWith(expect.objectContaining({
                projectPath: '/home/user/projects/test-repo',
                mrNumber: 123,
                platform: 'github',
            }));
            expect(deps.checkFollowupNeeded.execute).toHaveBeenCalledWith(expect.objectContaining({
                projectPath: '/home/user/projects/test-repo',
                mrNumber: 123,
                platform: 'github',
            }));
            expect(enqueueReview).toHaveBeenCalledWith(expect.objectContaining({
                jobType: 'followup',
                platform: 'github',
                mrNumber: 123,
            }), expect.any(Function));
            expect(mockReply.status).toHaveBeenCalledWith(202);
            expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'followup-queued', prNumber: 123 }));
        });
        it('does not enqueue when autoFollowup is disabled', async () => {
            const deps = buildFollowupDeps();
            const trackedMr = TrackedMrFactory.create({
                id: 'github-test-owner/test-repo-123',
                mrNumber: 123,
                platform: 'github',
                project: 'test-owner/test-repo',
                state: 'pending-fix',
                autoFollowup: false,
            });
            deps.recordPush.execute.mockReturnValue(trackedMr);
            const event = GitHubEventFactory.createSynchronizePr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(enqueueReview).not.toHaveBeenCalled();
            expect(mockReply.status).toHaveBeenCalledWith(200);
            expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'ignored', reason: 'Auto-followup disabled' }));
        });
        it('does not enqueue when checkFollowupNeeded returns false', async () => {
            const deps = buildFollowupDeps();
            deps.checkFollowupNeeded.execute.mockReturnValue(false);
            const event = GitHubEventFactory.createSynchronizePr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(enqueueReview).not.toHaveBeenCalled();
            expect(mockReply.status).toHaveBeenCalledWith(200);
        });
        it('does not enqueue when no MR is tracked (recordPush returns null)', async () => {
            const deps = buildFollowupDeps();
            deps.recordPush.execute.mockReturnValue(null);
            const event = GitHubEventFactory.createSynchronizePr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(enqueueReview).not.toHaveBeenCalled();
            expect(mockReply.status).toHaveBeenCalledWith(200);
        });
        it('rejects followup when enforceBudget denies it', async () => {
            const deps = buildFollowupDeps();
            deps.enforceBudget.execute.mockResolvedValue({
                accepted: false,
                status: {
                    limitUsd: 200,
                    consumedUsd: 200.1,
                    remainingUsd: 0,
                    percentUsed: 100.05,
                    exceeded: true,
                    periodStart: '2026-05-01T00:00:00.000Z',
                },
            });
            const event = GitHubEventFactory.createSynchronizePr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(enqueueReview).not.toHaveBeenCalled();
            expect(deps.broadcastBudgetExceeded).toHaveBeenCalledWith(expect.objectContaining({
                mrNumber: 123,
                platform: 'github',
                projectPath: 'test-owner/test-repo',
            }));
            expect(mockReply.status).toHaveBeenCalledWith(200);
            expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'rejected', reason: 'budget-exceeded' }));
        });
        it('does not enqueue followup on draft PR synchronize', async () => {
            const deps = buildFollowupDeps();
            const event = GitHubEventFactory.createPullRequestEvent({
                action: 'synchronize',
                pull_request: { state: 'open', draft: true },
            });
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(enqueueReview).not.toHaveBeenCalled();
            expect(deps.recordPush.execute).not.toHaveBeenCalled();
            expect(mockReply.status).toHaveBeenCalledWith(200);
        });
    });
    describe('cross-fork PR detection (FR-8)', () => {
        it('populates ReviewJob.sourceForkCloneUrl on a fresh review when head.repo differs from base.repo', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            event.pull_request.head.repo = {
                full_name: 'contributor/test-repo',
                clone_url: 'https://github.com/contributor/test-repo.git',
            };
            event.pull_request.base.repo = {
                full_name: 'test-owner/test-repo',
            };
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(enqueueReview).toHaveBeenCalledWith(expect.objectContaining({
                jobType: 'review',
                sourceForkCloneUrl: 'https://github.com/contributor/test-repo.git',
            }), expect.any(Function));
        });
        it('leaves ReviewJob.sourceForkCloneUrl undefined for a same-repo review', async () => {
            const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
            event.pull_request.head.repo = {
                full_name: 'test-owner/test-repo',
                clone_url: 'https://github.com/test-owner/test-repo.git',
            };
            event.pull_request.base.repo = {
                full_name: 'test-owner/test-repo',
            };
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);
            expect(enqueueReview).toHaveBeenCalled();
            const enqueuedJob = vi.mocked(enqueueReview).mock.calls[0][0];
            expect(enqueuedJob.sourceForkCloneUrl).toBeUndefined();
        });
        it('propagates sourceForkCloneUrl to the followup job on cross-fork synchronize', async () => {
            const deps = createMockDeps();
            const trackedMr = TrackedMrFactory.create({
                id: 'github-test-owner/test-repo-123',
                mrNumber: 123,
                platform: 'github',
                project: 'test-owner/test-repo',
                state: 'pending-fix',
                openThreads: 3,
                totalThreads: 3,
                lastPushAt: '2026-05-20T12:00:00Z',
                lastReviewAt: '2026-05-20T10:00:00Z',
                autoFollowup: true,
            });
            deps.recordPush.execute.mockReturnValue(trackedMr);
            deps.checkFollowupNeeded.execute.mockReturnValue(true);
            const event = GitHubEventFactory.createSynchronizePr();
            event.pull_request.head.repo = {
                full_name: 'contributor/test-repo',
                clone_url: 'https://github.com/contributor/test-repo.git',
            };
            event.pull_request.base.repo = {
                full_name: 'test-owner/test-repo',
            };
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(enqueueReview).toHaveBeenCalledWith(expect.objectContaining({
                jobType: 'followup',
                sourceForkCloneUrl: 'https://github.com/contributor/test-repo.git',
            }), expect.any(Function));
        });
    });
    describe('worktree cleanup on close', () => {
        it('calls removeWorktree on PR close with github identity (covers both closed and merged)', async () => {
            const removeWorktree = vi.fn(async () => ({ status: 'removed' }));
            const deps = { ...createMockDeps(), removeWorktree };
            const event = GitHubEventFactory.createClosedPr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(removeWorktree).toHaveBeenCalledWith(expect.objectContaining({
                identity: expect.objectContaining({
                    platform: 'github',
                    mrNumber: 123,
                }),
            }));
            expect(mockReply.status).toHaveBeenCalledWith(200);
        });
        it('keeps webhook response success when removeWorktree fails on close', async () => {
            const removeWorktree = vi.fn(async () => ({ status: 'failed', warning: 'boom' }));
            const deps = { ...createMockDeps(), removeWorktree };
            const event = GitHubEventFactory.createClosedPr();
            const request = { body: event, headers: {} };
            await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);
            expect(removeWorktree).toHaveBeenCalled();
            expect(mockReply.status).toHaveBeenCalledWith(200);
            expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'cleaned' }));
        });
    });
});
//# sourceMappingURL=github.controller.test.js.map