fix(github): recover oversized issue creation (#8217)

* fix(github): recover oversized issue creation (#7704)

* fix(github): preserve partial issue creation

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Rod Boev
2026-07-10 23:16:13 -07:00
committed by GitHub
co-authored by Jinjing
parent 66cad529a2
commit b677b2a209
7 changed files with 308 additions and 45 deletions
+157
View File
@@ -239,6 +239,163 @@ describe('issue source operations', () => {
)
})
it('recovers issue 7704 oversized inline-image creation', async () => {
const imagePrefix = 'data:image/png;base64,'
const body = imagePrefix + 'x'.repeat(133596 - imagePrefix.length)
expect(body).toContain('data:image')
expect(body).toHaveLength(133596)
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('HTTP 422: body is too long (maximum is 65536 characters)'))
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 926,
html_url: 'https://github.com/stablyai/orca/issues/926'
})
})
.mockResolvedValueOnce({ stdout: '' })
await expect(
createIssue('/repo-root', 'Image issue', body, undefined, undefined, {
labels: ['bug'],
assignees: ['octo']
})
).resolves.toEqual({
ok: true,
number: 926,
url: 'https://github.com/stablyai/orca/issues/926'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
`body=${body}`,
'title=Image issue',
'labels[]=bug',
'assignees[]=octo'
]),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`],
{ cwd: '/repo-root' }
)
})
it('recognizes the oversized-body response from structured gh stderr', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh'), {
stderr: 'gh: body is too long (maximum is 65536 characters) (HTTP 422)'
})
)
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 929,
html_url: 'https://github.com/stablyai/orca/issues/929'
})
})
.mockResolvedValueOnce({ stdout: '' })
await expect(createIssue('/repo-root', 'Image issue', 'data:image')).resolves.toEqual({
ok: true,
number: 929,
url: 'https://github.com/stablyai/orca/issues/929'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
})
it('does not retry unrelated create failures', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: assignees is invalid'))
await expect(createIssue('/repo-root', 'Invalid issue', 'Body')).resolves.toEqual({
ok: false,
error: 'HTTP 422: assignees is invalid'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('stops when placeholder create fails during oversized-body recovery', async () => {
const body = `data:image/png;base64,${'x'.repeat(100)}`
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)'))
.mockRejectedValueOnce(new Error('HTTP 500: create failed'))
await expect(createIssue('/repo-root', 'Placeholder failure', body)).resolves.toEqual({
ok: false,
error: 'HTTP 500: create failed'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('preserves fields during oversized-body recovery', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
const body = `data:image/png;base64,${'x'.repeat(100)}`
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)'))
.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 927, url: 'issue-url' }) })
.mockResolvedValueOnce({ stdout: '' })
await expect(
createIssue(
'/repo-root',
'Fields issue',
body,
undefined,
null,
{
labels: ['bug'],
assignees: ['octo']
},
localGitOptions
)
).resolves.toEqual({ ok: true, number: 927, url: 'issue-url' })
const firstCreateArgs = [...ghExecFileAsyncMock.mock.calls[0][0]]
const fallbackCreateArgs = [...ghExecFileAsyncMock.mock.calls[1][0]]
firstCreateArgs[firstCreateArgs.indexOf(`body=${body}`)] = 'body='
expect(fallbackCreateArgs).toEqual(firstCreateArgs)
expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
true
)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo-root',
undefined,
null,
localGitOptions
)
})
it('reports partial success when oversized-body patch fails', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)'))
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 928,
html_url: 'https://github.com/stablyai/orca/issues/928'
})
})
.mockRejectedValueOnce(new Error('HTTP 500: update failed'))
await expect(createIssue('/repo-root', 'Partial issue', 'data:image')).resolves.toEqual({
ok: true,
number: 928,
url: 'https://github.com/stablyai/orca/issues/928',
bodySaveWarning:
'Issue https://github.com/stablyai/orca/issues/928 was created, but saving its body failed: HTTP 500: update failed'
})
})
it('updates issue body through the REST issue endpoint', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
+71 -20
View File
@@ -6,6 +6,7 @@ import type {
ClassifiedError,
GitHubAssignableUser,
GitHubCreateIssueFields,
GitHubCreateIssueResult,
GitHubCommentResult,
GitHubIssueUpdate,
IssueInfo,
@@ -15,7 +16,7 @@ import type {
import { mapIssueInfo } from './mappers'
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
// prettier-ignore
import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } from './gh-utils'
import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils'
// Why: distinguishes a successful-empty listing from a failed fetch. The
// previous `catch { return [] }` conflated a 403 on a private upstream with an
@@ -31,6 +32,11 @@ export type IssueListResult = {
error?: ClassifiedError
}
function githubIssueErrorMessage(error: unknown): string {
const { stderr, stdout } = extractExecError(error)
return stderr.trim() || stdout.trim()
}
/**
* Get a single issue by number.
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
@@ -162,7 +168,7 @@ export async function createIssue(
connectionId?: string | null,
fields?: GitHubCreateIssueFields,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> {
): Promise<GitHubCreateIssueResult> {
const trimmedTitle = title.trim()
if (!trimmedTitle) {
return { ok: false, error: 'Title is required' }
@@ -180,25 +186,71 @@ export async function createIssue(
}
await acquire()
try {
const args = [
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`,
'--raw-field',
`title=${trimmedTitle}`,
'--raw-field',
`body=${body}`
]
for (const label of fields?.labels ?? []) {
args.push('--raw-field', `labels[]=${label}`)
const createArgs = (issueBody: string) => {
const args = [
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`,
'--raw-field',
`title=${trimmedTitle}`,
'--raw-field',
`body=${issueBody}`
]
for (const label of fields?.labels ?? []) {
args.push('--raw-field', `labels[]=${label}`)
}
for (const assignee of fields?.assignees ?? []) {
args.push('--raw-field', `assignees[]=${assignee}`)
}
return args
}
for (const assignee of fields?.assignees ?? []) {
args.push('--raw-field', `assignees[]=${assignee}`)
const parseIssue = (stdout: string) =>
JSON.parse(stdout) as { number?: number; html_url?: string; url?: string }
let data: { number?: number; html_url?: string; url?: string }
try {
const { stdout } = await ghExecFileAsync(createArgs(body), ghOptions)
data = parseIssue(stdout)
} catch (err) {
const message = githubIssueErrorMessage(err)
if (!/body is too long \(maximum is \d+ characters\)/i.test(message)) {
return { ok: false, error: message }
}
// Why: GitHub rejects oversized bodies on create but accepts the same body
// on update, so establish the issue before attaching its body.
const { stdout } = await ghExecFileAsync(createArgs(''), ghOptions)
data = parseIssue(stdout)
if (typeof data.number !== 'number') {
return { ok: false, error: 'Unexpected response from GitHub' }
}
try {
await ghExecFileAsync(
[
'api',
'-X',
'PATCH',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${data.number}`,
'--raw-field',
`body=${body}`
],
ghOptions
)
} catch (patchErr) {
const patchMessage = githubIssueErrorMessage(patchErr)
const identity = data.html_url ?? data.url ?? `#${data.number}`
return {
ok: true,
number: data.number,
url: String(data.html_url ?? data.url ?? ''),
bodySaveWarning: `Issue ${identity} was created, but saving its body failed: ${patchMessage}`
}
}
}
const { stdout } = await ghExecFileAsync(args, ghOptions)
const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string }
if (typeof data.number !== 'number') {
return { ok: false, error: 'Unexpected response from GitHub' }
}
@@ -208,8 +260,7 @@ export async function createIssue(
url: String(data.html_url ?? data.url ?? '')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: message }
return { ok: false, error: githubIssueErrorMessage(err) }
} finally {
release()
}