Files
orca/src/main/github/project-view/mutations.test.ts
T
Žan MlakarandNeil 4dca163bb7 fix(github): count gh pr and issue commands against the GraphQL rate limit (#28778)
* fix(github): count gh pr and issue commands against the GraphQL rate limit

gh serves pr/issue/repo view-style subcommands over GraphQL, but the breaker
filed every non-api command under core and only matched REST's
"API rate limit exceeded" wording, so GraphQL exhaustion never tripped it.
Map those subcommands to the graphql bucket, match GraphQL's
"API rate limit already exceeded", and guard project-view issue close/reopen
against the GraphQL quota.

Fixes #28767

* test(github): drop redundant Date.now() args in graphql breaker test

Keeps the flaky-test-pattern ratchet at its baseline.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-10-27 22:57:10 -07:00

256 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
acquireMock,
ghExecFileAsyncMock,
noteRepositoryRateLimitSpendMock,
projectHostAuthenticationErrorMock,
repositoryRateLimitGuardMock,
releaseMock,
runGraphqlMock,
runRestMock
} = vi.hoisted(() => ({
acquireMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
noteRepositoryRateLimitSpendMock: vi.fn(),
projectHostAuthenticationErrorMock: vi.fn(),
repositoryRateLimitGuardMock: vi.fn(),
releaseMock: vi.fn(),
runGraphqlMock: vi.fn(),
runRestMock: vi.fn()
}))
vi.mock('./internals', () => ({
acquire: acquireMock,
release: releaseMock,
extractExecError: (err: unknown) => ({
stderr: err instanceof Error ? err.message : String(err),
stdout: ''
}),
ghExecFileAsync: ghExecFileAsyncMock,
rateLimitGuard: () => ({ blocked: false }),
noteRateLimitSpend: vi.fn(),
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock,
projectHostAuthenticationError: projectHostAuthenticationErrorMock,
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }),
rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }),
runGraphql: runGraphqlMock,
runRest: runRestMock,
validateSlugArgs: (owner: string, repo: string) =>
owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } },
assertPositiveInt: (value: number, name: string) =>
Number.isInteger(value) && value > 0
? { ok: true, value }
: { ok: false, error: { type: 'validation_error', message: `${name} invalid` } }
}))
import { updateIssueBySlug } from './mutations'
import { getWorkItemDetailsBySlug } from './work-item-details'
describe('updateIssueBySlug', () => {
beforeEach(() => {
acquireMock.mockReset().mockResolvedValue(undefined)
ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
noteRepositoryRateLimitSpendMock.mockReset()
projectHostAuthenticationErrorMock.mockReset().mockResolvedValue(null)
repositoryRateLimitGuardMock.mockReset().mockReturnValue({ blocked: false })
releaseMock.mockReset()
runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} })
})
it('closes slug-addressed issues with completed and not planned reasons via gh issue close', async () => {
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
number: 12,
updates: { state: 'closed', stateReason: 'completed' }
})
).resolves.toEqual({ ok: true })
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
number: 13,
updates: { state: 'closed', stateReason: 'not_planned' }
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'],
{ encoding: 'utf-8', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'],
{ encoding: 'utf-8', host: 'github.com' }
)
expect(runRestMock).not.toHaveBeenCalled()
})
it('rejects an unconfigured Enterprise host before spawning gh', async () => {
projectHostAuthenticationErrorMock.mockResolvedValue({
type: 'auth_required',
message: 'GitHub host is not authenticated'
})
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
host: 'evil.example.test',
number: 12,
updates: { state: 'closed' }
})
).resolves.toMatchObject({ ok: false, error: { type: 'auth_required' } })
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(runRestMock).not.toHaveBeenCalled()
})
it('guards and accounts state mutations in the selected host GraphQL quota', async () => {
const args = {
owner: 'acme',
repo: 'widgets',
host: 'github.corp.example',
number: 12,
updates: { state: 'closed' as const }
}
await expect(updateIssueBySlug(args)).resolves.toEqual({ ok: true })
expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(args, 'graphql')
expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith(args, 'graphql')
})
it('does not spawn a state mutation while that host quota is blocked', async () => {
repositoryRateLimitGuardMock.mockReturnValue({ blocked: true })
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
host: 'github.corp.example',
number: 12,
updates: { state: 'closed' }
})
).resolves.toMatchObject({ ok: false, error: { type: 'rate_limited' } })
expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('closes slug-addressed duplicate issues with --duplicate-of', async () => {
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
number: 12,
updates: { state: 'closed', stateReason: 'duplicate', duplicateOf: 99 }
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'],
{ encoding: 'utf-8', host: 'github.com' }
)
expect(runRestMock).not.toHaveBeenCalled()
})
it('rejects duplicate closes without a target before spawning gh', async () => {
const result = await updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
number: 12,
updates: { state: 'closed', stateReason: 'duplicate' }
})
expect(result).toMatchObject({
ok: false,
error: { type: 'validation_error' }
})
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(runRestMock).not.toHaveBeenCalled()
})
it('reopens slug-addressed issues via gh issue reopen', async () => {
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
number: 12,
updates: { state: 'open' }
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'reopen', '12', '--repo', 'acme/widgets'],
{ encoding: 'utf-8', host: 'github.com' }
)
})
it('threads the GHES host into gh exec and REST options', async () => {
await expect(
updateIssueBySlug({
owner: 'acme',
repo: 'widgets',
number: 12,
host: 'github.corp.example',
updates: { state: 'closed', stateReason: 'completed', title: 'New title' }
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'],
{ encoding: 'utf-8', host: 'github.corp.example' }
)
expect(runRestMock).toHaveBeenCalledWith(
['-X', 'PATCH', 'repos/acme/widgets/issues/12', '--raw-field', 'title=New title'],
undefined,
'core',
{ host: 'github.corp.example' }
)
})
})
describe('getWorkItemDetailsBySlug', () => {
beforeEach(() => {
runGraphqlMock.mockReset()
})
it('threads the GHES host into the GraphQL exec options', async () => {
runGraphqlMock.mockResolvedValue({
ok: true,
data: {
repository: {
issue: {
id: 'I_1',
number: 7,
title: 'Enterprise issue',
url: 'https://github.corp.example/acme/widgets/issues/7',
state: 'OPEN'
}
}
}
})
const result = await getWorkItemDetailsBySlug({
owner: 'acme',
repo: 'widgets',
number: 7,
type: 'issue',
host: 'github.corp.example'
})
expect(result.ok).toBe(true)
expect(runGraphqlMock).toHaveBeenCalledWith(
expect.any(String),
{ owner: 'acme', repo: 'widgets', num: 7 },
{ host: 'github.corp.example' }
)
})
})