import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted( () => ({ ghExecFileAsyncMock: vi.fn(), resolveIssueSourceMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() }) ) vi.mock('./gh-utils', async () => { const actual = await vi.importActual('./gh-utils') return { ...actual, ghExecFileAsync: ghExecFileAsyncMock, acquire: acquireMock, release: releaseMock } }) vi.mock('./github-api-repository', async (importOriginal) => ({ ...(await importOriginal()), resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock })) import { addIssueComment } from './issue-comment' import { updateIssue } from './issue-update' const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } describe('issue mutations follow the issue source preference', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() acquireMock.mockReset() acquireMock.mockResolvedValue(undefined) releaseMock.mockReset() resolveIssueSourceMock.mockReset() resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({ source: preference === 'origin' ? ORIGIN : UPSTREAM, fellBack: false })) }) it('posts issue comments to origin when origin is selected', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' }) }) await expect( addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin') ).resolves.toMatchObject({ ok: true }) expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( 'repos/fork-owner/widgets/issues/5/comments' ) }) it('keeps an explicit owner/repo override ahead of the preference', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' }) }) await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin') expect(resolveIssueSourceMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( 'repos/upstream-owner/widgets/issues/7/comments' ) }) it('edits issues on origin when origin is selected', async () => { ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) await expect( updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin') ).resolves.toEqual({ ok: true }) expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5') expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ 'issue', 'edit', '5', '--repo', 'fork-owner/widgets', '--add-assignee', 'octo' ]) }) it('keeps the upstream-first default when no preference is set', async () => { ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) await updateIssue('/repo-root', 5, { body: 'Updated' }) expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {}) expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5') }) it('keeps edits on the opened issue when the saved preference changes', async () => { ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) await updateIssue( '/repo-root', 5, { body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] }, null, {}, 'upstream', ORIGIN ) expect(resolveIssueSourceMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ ['issue', 'close', '5', '--repo', 'fork-owner/widgets'], [ 'api', '-X', 'PATCH', 'repos/fork-owner/widgets/issues/5', '--raw-field', 'body=Origin edit' ], [ 'issue', 'edit', '5', '--repo', 'fork-owner/widgets', '--add-label', 'bug', '--add-assignee', 'octo' ] ]) }) it('preserves local WSL execution with an explicit issue repository', async () => { ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) await updateIssue( '/home/fixture/widgets', 5, { body: 'Origin edit' }, null, { wslDistro: 'Ubuntu' }, 'upstream', ORIGIN ) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( expect.arrayContaining(['repos/fork-owner/widgets/issues/5']), expect.objectContaining({ cwd: '/home/fixture/widgets', wslDistro: 'Ubuntu', host: 'github.com' }) ) }) it('refuses unresolved Origin mutations instead of using ambient gh', async () => { resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false }) await expect( updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin') ).resolves.toMatchObject({ ok: false }) await expect( addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin') ).resolves.toMatchObject({ ok: false }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) })