import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GhUtils from './gh-utils' const { ghExecFileAsyncMock, getIssueOwnerRepoMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getIssueOwnerRepoMock: 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, getIssueOwnerRepo: getIssueOwnerRepoMock, resolveIssueSource: resolveIssueSourceMock, acquire: acquireMock, release: releaseMock } }) import { createIssue, getIssue, listIssues } from './issues' describe('issue source operations', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getIssueOwnerRepoMock.mockReset() resolveIssueSourceMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) // Why: preference-aware paths call resolveIssueSource instead of // getIssueOwnerRepo. Route through the same mock so existing tests that // set up getIssueOwnerRepoMock continue to work. resolveIssueSourceMock.mockImplementation(async () => ({ source: await getIssueOwnerRepoMock(), fellBack: false })) }) it('gets a single issue from the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 923, title: 'Use upstream issues', state: 'open', html_url: 'https://github.com/stablyai/orca/issues/923', labels: [] }) }) await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], { cwd: '/repo-root' } ) }) it('lists issues from the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'api', '--cache', '120s', 'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc' ], { cwd: '/repo-root' } ) }) it('surfaces a classified permission_denied error instead of collapsing to empty', async () => { // Why: parent design doc §3 — a 403 on a private upstream must not // masquerade as "No issues". The envelope carries an error the UI can // render as a banner with retry, not a silent empty list. getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockRejectedValueOnce( new Error('HTTP 403: Resource not accessible by integration') ) const result = await listIssues('/repo-root', 5) expect(result.items).toEqual([]) expect(result.error?.type).toBe('permission_denied') }) it('creates issues in the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 924, html_url: 'https://github.com/stablyai/orca/issues/924' }) }) await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({ ok: true, number: 924, url: 'https://github.com/stablyai/orca/issues/924' }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'api', '-X', 'POST', 'repos/stablyai/orca/issues', '--raw-field', 'title=New issue', '--raw-field', 'body=Body' ], { cwd: '/repo-root' } ) }) })