Files
orca/src/main/github/issue-mutation-source.test.ts
T
Wooseong KimandNeil 6ad0a42425 fix(github): send issue bodies to gh over stdin instead of argv (#25215)
Issue create (including the empty-body fallback and follow-up PATCH),
issue body edits, and issue comments passed the body as
`--raw-field body=...` on argv. Windows caps the command line at
32,767 chars, so bodies with pasted inline images failed with
`spawn ENAMETOOLONG` before reaching GitHub.

These calls now use `gh api -X <METHOD> <endpoint> --input -` with a
JSON payload on stdin. ghExecFileWithScopeAsync also now forwards
`options.stdin` to the child process; it was previously dropped.

Fixes #15832

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-26 03:26:35 -07:00

175 lines
5.6 KiB
TypeScript

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<typeof GhUtils>('./gh-utils')
return {
...actual,
ghExecFileAsync: ghExecFileAsyncMock,
acquire: acquireMock,
release: releaseMock
}
})
vi.mock('./github-api-repository', async (importOriginal) => ({
...(await importOriginal<typeof GithubApiRepositoryModule>()),
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', '--input', '-'],
[
'issue',
'edit',
'5',
'--repo',
'fork-owner/widgets',
'--add-label',
'bug',
'--add-assignee',
'octo'
]
])
expect(ghExecFileAsyncMock.mock.calls[1][1]?.stdin).toBe(
JSON.stringify({ body: 'Origin edit' })
)
})
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()
})
})