import { setImmediate as nextTurn } from 'node:timers/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' const mocks = vi.hoisted(() => ({ getStagedCommitContext: vi.fn(), gitExecFileAsync: vi.fn(), getPullRequestDraftContext: vi.fn(), loadPullRequestLinkedIssue: vi.fn(), prepareLocalCommitMessageAgentEnv: vi.fn(), generateCommitMessageFromContext: vi.fn(), generatePullRequestFieldsFromContext: vi.fn(), resolveHostedReviewBodyForGeneration: vi.fn() })) const linkedLookup = vi.hoisted(() => ({ run: (): Promise => Promise.resolve(null) })) vi.mock('../git/status', () => ({ getStagedCommitContext: mocks.getStagedCommitContext })) vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) vi.mock('../text-generation/pull-request-context', () => ({ getPullRequestDraftContext: mocks.getPullRequestDraftContext })) vi.mock('../source-control/pull-request-linked-issue', () => ({ loadPullRequestLinkedIssue: (...args: unknown[]) => { mocks.loadPullRequestLinkedIssue(...args) // A mock return observer would handle the rejection being tested. return linkedLookup.run() } })) vi.mock('../text-generation/commit-message-agent-environment', () => ({ prepareLocalCommitMessageAgentEnv: mocks.prepareLocalCommitMessageAgentEnv })) vi.mock('../text-generation/commit-message-text-generation', () => ({ generateCommitMessageFromContext: mocks.generateCommitMessageFromContext, generatePullRequestFieldsFromContext: mocks.generatePullRequestFieldsFromContext })) vi.mock('../source-control/pull-request-template', () => ({ resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration })) import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { RuntimeGitGenerationCommands } from './runtime-git-generation-commands' const params = { agentId: 'codex' as const, model: 'gpt-5.5' } const settingsOverride = { sourceControlAiResolvedParams: params } const pullRequestContext = { base: 'main', branch: 'feature/admission', commitSummary: 'abc123 feat: test', changeSummary: 'M README.md', patch: '+hello', currentTitle: '', currentBody: '', currentDraft: false } function makeTarget(path: string, overrides: Partial = {}): RuntimeGitTarget { return { worktree: { id: 'wt-1', repoId: 'repo-1', path, git: { path, branch: 'main', isBare: false, isMainWorktree: false, head: 'a'.repeat(40) } } as RuntimeGitTarget['worktree'], executionHostId: 'local', ...overrides } } function makeCommands(target: RuntimeGitTarget): RuntimeGitGenerationCommands { const host: RuntimeGitCommandHost = { resolveRuntimeGitTarget: async () => target, getRuntimeSettings: () => ({}) as GlobalSettings } return new RuntimeGitGenerationCommands(host) } describe('RuntimeGitGenerationCommands admission', () => { beforeEach(() => { vi.clearAllMocks() mocks.gitExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }) linkedLookup.run = () => Promise.resolve(null) mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body) mocks.prepareLocalCommitMessageAgentEnv.mockResolvedValue({ ok: true, env: {} }) mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'feat' }) mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} }) }) it('marks local staged commit context as interactive without dropping WSL routing', async () => { mocks.getStagedCommitContext.mockResolvedValue({ branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+hello' }) const commands = makeCommands( makeTarget('C:\\repo', { localGitOptions: { wslDistro: 'Ubuntu' } }) ) await commands.generateRuntimeCommitMessage('id:wt-1', settingsOverride) expect(mocks.getStagedCommitContext).toHaveBeenCalledWith('C:\\repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' }) }) it('marks local pull-request context and linked-issue reads as interactive', async () => { mocks.getPullRequestDraftContext.mockImplementation(async (execute) => { await execute(['fetch', 'origin', 'main'], { timeout: 123 }) await execute(['show-ref', '--verify'], { maxBuffer: 456, timeoutMs: 789 }) return pullRequestContext }) const commands = makeCommands( makeTarget('C:\\repo', { localGitOptions: { wslDistro: 'Ubuntu' } }) ) await commands.generateRuntimePullRequestFields( 'id:wt-1', { base: 'main', title: '', body: '', draft: false }, settingsOverride ) expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(['fetch', 'origin', 'main'], { cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: 123, admissionTier: 'interactive' }) expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(['show-ref', '--verify'], { cwd: 'C:\\repo', wslDistro: 'Ubuntu', maxBuffer: 456, timeout: 789, admissionTier: 'interactive' }) expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith( expect.objectContaining({ localGitOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } }) ) }) it('reports an SSH pull-request target unserved without reading git anywhere', async () => { const commands = makeCommands(makeTarget('/remote/repo', { executionHostId: 'ssh:conn-1' })) await expect( commands.generateRuntimePullRequestFields( 'id:wt-1', { base: 'main', title: '', body: '', draft: false }, settingsOverride ) ).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }) expect(mocks.getPullRequestDraftContext).not.toHaveBeenCalled() expect(mocks.gitExecFileAsync).not.toHaveBeenCalled() expect(mocks.loadPullRequestLinkedIssue).not.toHaveBeenCalled() }) describe('linked-issue lifetime', () => { function commands(): RuntimeGitGenerationCommands { return makeCommands(makeTarget('/repo')) } const input = { base: 'main', title: '', body: '', draft: false, provider: 'gitlab' as const } it.each(['no changes', 'context error', 'template error'])( 'observes a late lookup rejection after %s', async (outcome) => { const issue = Promise.withResolvers() linkedLookup.run = () => issue.promise const preparationError = new Error('preparation failed') mocks.getPullRequestDraftContext.mockResolvedValue(pullRequestContext) if (outcome === 'no changes') { mocks.getPullRequestDraftContext.mockResolvedValue(null) } else if (outcome === 'context error') { mocks.getPullRequestDraftContext.mockRejectedValue(preparationError) } else { mocks.resolveHostedReviewBodyForGeneration.mockRejectedValue(preparationError) } const unhandled = vi.fn() process.on('unhandledRejection', unhandled) try { await expect( commands().generateRuntimePullRequestFields('id:wt-1', input, settingsOverride) ).resolves.toEqual({ success: false, error: outcome === 'no changes' ? 'No branch changes to summarize.' : preparationError.message }) expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledOnce() issue.reject(new Error('Timed out waiting for a GitLab operation slot.')) await nextTurn() expect(unhandled).not.toHaveBeenCalled() expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled() } finally { issue.resolve(null) void issue.promise.catch(() => undefined) process.off('unhandledRejection', unhandled) } } ) it('observes rejection during preparation and preserves the later caller error', async () => { const issue = Promise.withResolvers() const preparation = Promise.withResolvers() const failure = new Error('lookup admission failed') linkedLookup.run = () => issue.promise mocks.getPullRequestDraftContext.mockReturnValue(preparation.promise) const unhandled = vi.fn() process.on('unhandledRejection', unhandled) const result = commands() .generateRuntimePullRequestFields('id:wt-1', input, settingsOverride) .catch((error: unknown) => error) try { await nextTurn() expect(mocks.getPullRequestDraftContext).toHaveBeenCalledOnce() issue.reject(failure) await nextTurn() expect(unhandled).not.toHaveBeenCalled() preparation.resolve(pullRequestContext) expect(await result).toBe(failure) expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled() } finally { issue.resolve(null) preparation.resolve(pullRequestContext) await result process.off('unhandledRejection', unhandled) } }) it('still rejects the caller after successful preparation when the lookup fails', async () => { const issue = Promise.withResolvers() const failure = new Error('lookup failed') linkedLookup.run = () => issue.promise mocks.getPullRequestDraftContext.mockResolvedValue(pullRequestContext) const result = commands() .generateRuntimePullRequestFields('id:wt-1', input, settingsOverride) .catch((error: unknown) => error) try { await nextTurn() issue.reject(failure) expect(await result).toBe(failure) expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled() } finally { issue.resolve(null) await result } }) }) })