mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Keeps a fork issue’s details, metadata and edits bound to the repository the user opened, including same-number issues in fork and upstream. Repairs selected-assignee leakage and delayed failed edits repainting another issue. Fixes #24378 Incorporates and cross-reviews contributor PR #24379, including its source-resolver correction and regression material. Covers the contributor PR’s Project-row identity and retained-dialog mutation findings. The final published head passes focused tests, hidden macOS rendering and current CI; the callback-timing bot thread has an evidence-based response. Co-authored-by: Katsuma Takehisa <k.takehisa@nissogr.com>
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
type RateLimitGuardResult =
|
|
| { blocked: false }
|
|
| { blocked: true; remaining: number; limit: number; resetAt: number }
|
|
|
|
const {
|
|
ghExecFileAsyncMock,
|
|
getEnterpriseGitHubRepoSlugMock,
|
|
getOwnerRepoMock,
|
|
getWorkItemMock,
|
|
getPRChecksMock,
|
|
getPRCommentsMock,
|
|
noteRepositoryRateLimitSpendMock,
|
|
repositoryRateLimitGuardMock
|
|
} = vi.hoisted(() => ({
|
|
ghExecFileAsyncMock: vi.fn(),
|
|
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
|
getOwnerRepoMock: vi.fn(),
|
|
getWorkItemMock: vi.fn(),
|
|
getPRChecksMock: vi.fn(),
|
|
getPRCommentsMock: vi.fn(),
|
|
noteRepositoryRateLimitSpendMock:
|
|
vi.fn<
|
|
(
|
|
repository: { host?: string } | null | undefined,
|
|
bucket: string,
|
|
cost?: number,
|
|
options?: { cwd?: string; host?: string }
|
|
) => void
|
|
>(),
|
|
repositoryRateLimitGuardMock: vi.fn<
|
|
(
|
|
repository: { host?: string } | null | undefined,
|
|
bucket: string,
|
|
options?: { cwd?: string; host?: string }
|
|
) => RateLimitGuardResult
|
|
>(() => ({ blocked: false }))
|
|
}))
|
|
|
|
vi.mock('./gh-utils', () => ({
|
|
ghExecFileAsync: ghExecFileAsyncMock,
|
|
getOwnerRepoForRemote: getOwnerRepoMock,
|
|
getIssueOwnerRepo: vi.fn(),
|
|
ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
|
|
githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
|
|
repoPath,
|
|
connectionId: connectionId ?? null,
|
|
...localGitOptions
|
|
})),
|
|
acquire: vi.fn(),
|
|
release: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./client', async () => ({
|
|
getWorkItem: getWorkItemMock,
|
|
getWorkItemWithRepository: (
|
|
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
|
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
|
getWorkItemByOwnerRepo: vi.fn(),
|
|
getPRChecks: getPRChecksMock,
|
|
getPRComments: getPRCommentsMock
|
|
}))
|
|
|
|
vi.mock('./github-enterprise-repository', () => ({
|
|
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock,
|
|
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
|
}))
|
|
|
|
vi.mock('./rate-limit', () => ({
|
|
rateLimitGuard: vi.fn(() => ({ blocked: false })),
|
|
noteRateLimitSpend: vi.fn(),
|
|
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
|
|
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock
|
|
}))
|
|
|
|
import { getPRFileContents, getWorkItemDetails } from './work-item-details'
|
|
|
|
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
|
|
|
// The origin-repository cache is module-level state; reset it so slugs
|
|
// resolved by one test cannot leak into the next.
|
|
beforeEach(() => {
|
|
_resetOriginGitHubApiRepositoryCache()
|
|
})
|
|
|
|
function pullRequestItem(number: number, title: string): Record<string, unknown> {
|
|
return {
|
|
id: `pr:${number}`,
|
|
type: 'pr',
|
|
number,
|
|
title,
|
|
state: 'open',
|
|
url: `https://github.com/acme/widgets/pull/${number}`,
|
|
labels: [],
|
|
updatedAt: '2026-07-16T00:00:00Z',
|
|
author: 'pr-author'
|
|
}
|
|
}
|
|
|
|
function auxiliaryPRResponse(args: string[]): { stdout: string } {
|
|
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
|
if (query.includes('viewerViewedState')) {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
id: 'PR_file_list',
|
|
files: { pageInfo: { hasNextPage: false }, nodes: [] }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
if (query.includes('participants(first: 100)')) {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
data: { repository: { pullRequest: { participants: { nodes: [] } } } }
|
|
})
|
|
}
|
|
}
|
|
return { stdout: JSON.stringify({ data: {} }) }
|
|
}
|
|
|
|
describe('getWorkItemDetails PR file listing', () => {
|
|
beforeEach(() => {
|
|
ghExecFileAsyncMock.mockReset()
|
|
getOwnerRepoMock.mockReset()
|
|
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
getEnterpriseGitHubRepoSlugMock.mockReset()
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
|
getWorkItemMock.mockReset()
|
|
getPRChecksMock.mockReset()
|
|
getPRChecksMock.mockResolvedValue([])
|
|
getPRCommentsMock.mockReset()
|
|
getPRCommentsMock.mockResolvedValue([])
|
|
noteRepositoryRateLimitSpendMock.mockReset()
|
|
repositoryRateLimitGuardMock.mockReset()
|
|
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
|
|
})
|
|
|
|
it('loads files beyond the first 100-result REST page', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce(pullRequestItem(108, 'Large PR'))
|
|
const restFile = (index: number) => ({
|
|
filename: `src/file-${index}.ts`,
|
|
status: 'modified',
|
|
additions: 1,
|
|
deletions: 0,
|
|
changes: 1,
|
|
patch: '@@ -1 +1 @@'
|
|
})
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
|
|
if (endpoint === 'repos/acme/widgets/pulls/108') {
|
|
return { stdout: JSON.stringify({ head: { sha: 'head' }, base: { sha: 'base' } }) }
|
|
}
|
|
if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100') {
|
|
return {
|
|
stdout: JSON.stringify(Array.from({ length: 100 }, (_, index) => restFile(index)))
|
|
}
|
|
}
|
|
if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100&page=2') {
|
|
return { stdout: JSON.stringify([restFile(100)]) }
|
|
}
|
|
return auxiliaryPRResponse(args)
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 108, 'pr')
|
|
|
|
expect(details?.files).toHaveLength(101)
|
|
expect(details?.files?.at(-1)?.path).toBe('src/file-100.ts')
|
|
const fileEndpoints = ghExecFileAsyncMock.mock.calls
|
|
.map(([args]) => (args as string[]).find((arg) => arg.includes('/files?')))
|
|
.filter(Boolean)
|
|
expect(fileEndpoints).toEqual([
|
|
'repos/acme/widgets/pulls/108/files?per_page=100',
|
|
'repos/acme/widgets/pulls/108/files?per_page=100&page=2'
|
|
])
|
|
expect(
|
|
noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core')
|
|
).toHaveLength(3)
|
|
expect(
|
|
repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core')
|
|
).toHaveLength(3)
|
|
})
|
|
|
|
// Why: a rate-limited/auth-failed file fetch must not render as an empty PR;
|
|
// the Files tab keys its retry state off details.filesUnavailable.
|
|
it('flags filesUnavailable when the file fetch fails', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8305, 'Files fetch fails'))
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const target = args.at(-1)
|
|
if (target === 'repos/acme/widgets/pulls/8305') {
|
|
return {
|
|
stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } })
|
|
}
|
|
}
|
|
if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') {
|
|
throw new Error('gh: API rate limit exceeded (403)')
|
|
}
|
|
return auxiliaryPRResponse(args)
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 8305, 'pr')
|
|
|
|
expect(details?.filesUnavailable).toBe(true)
|
|
expect(details?.files).toBeUndefined()
|
|
})
|
|
|
|
it('preserves an empty file list as an available result', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8306, 'Empty PR'))
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const target = args.at(-1)
|
|
if (target === 'repos/acme/widgets/pulls/8306') {
|
|
return {
|
|
stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } })
|
|
}
|
|
}
|
|
if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') {
|
|
return { stdout: '[]' }
|
|
}
|
|
return auxiliaryPRResponse(args)
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 8306, 'pr')
|
|
|
|
expect(details?.filesUnavailable).toBe(false)
|
|
expect(details?.files).toEqual([])
|
|
})
|
|
|
|
it('backfills the Enterprise origin host before a host-less PR detail fan-out', async () => {
|
|
const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
...pullRequestItem(8, 'Host-less Enterprise PR'),
|
|
author: '',
|
|
prRepo: { owner: 'team', repo: 'orca' }
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterprise)
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
|
|
if (endpoint === 'repos/team/orca/pulls/8') {
|
|
return { stdout: JSON.stringify({ body: 'Enterprise body' }) }
|
|
}
|
|
if (endpoint === 'repos/team/orca/pulls/8/files?per_page=100') {
|
|
return { stdout: '[]' }
|
|
}
|
|
return auxiliaryPRResponse(args)
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/remote/repo', 8, 'pr', 'ssh-1')
|
|
|
|
expect(details?.item.prRepo).toEqual(enterprise)
|
|
expect(getPRCommentsMock).toHaveBeenCalledWith(
|
|
'/remote/repo',
|
|
8,
|
|
{ prRepo: enterprise },
|
|
'ssh-1'
|
|
)
|
|
expect(getPRChecksMock).toHaveBeenCalledWith(
|
|
'/remote/repo',
|
|
8,
|
|
undefined,
|
|
enterprise,
|
|
undefined,
|
|
'ssh-1'
|
|
)
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.every(([, options]) => options?.host === enterprise.host)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('does not run bare PR detail commands when local host resolution fails', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
...pullRequestItem(9, 'Unresolved PR'),
|
|
prRepo: { owner: 'team', repo: 'orca' }
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 9, 'pr')
|
|
|
|
expect(details).toMatchObject({ body: '', comments: [], checks: [], filesUnavailable: true })
|
|
expect(getPRCommentsMock).not.toHaveBeenCalled()
|
|
expect(getPRChecksMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the selected upstream GitHub Enterprise repo for both PR file sides', async () => {
|
|
const prRepo = {
|
|
owner: 'team',
|
|
repo: 'orca',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
|
|
if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=base-sha') {
|
|
return { stdout: 'base content' }
|
|
}
|
|
if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=head-sha') {
|
|
return { stdout: 'head content' }
|
|
}
|
|
throw new Error(`unexpected gh call: ${args.join(' ')}`)
|
|
})
|
|
|
|
const contents = await getPRFileContents({
|
|
repoPath: '/repo-root',
|
|
prRepo,
|
|
prNumber: 7,
|
|
path: 'src/path#with?chars.ts',
|
|
status: 'modified',
|
|
headSha: 'head-sha',
|
|
baseSha: 'base-sha'
|
|
})
|
|
|
|
expect(contents).toMatchObject({
|
|
original: 'base content',
|
|
modified: 'head content',
|
|
originalIsBinary: false,
|
|
modifiedIsBinary: false
|
|
})
|
|
const apiCalls = ghExecFileAsyncMock.mock.calls.map(([args]) => args as string[])
|
|
expect(apiCalls).toHaveLength(2)
|
|
expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true)
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.every(
|
|
([, options]) => options?.host === 'github.acme-corp.com'
|
|
)
|
|
).toBe(true)
|
|
expect(
|
|
repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core')
|
|
).toHaveLength(2)
|
|
expect(
|
|
noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core')
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it('skips an added file base read and preserves a head max-buffer overflow', async () => {
|
|
ghExecFileAsyncMock.mockRejectedValueOnce(
|
|
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
|
|
)
|
|
|
|
const contents = await getPRFileContents({
|
|
repoPath: '/repo-root',
|
|
prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' },
|
|
prNumber: 7,
|
|
path: 'src/large.ts',
|
|
status: 'added',
|
|
headSha: 'head-sha',
|
|
baseSha: 'base-sha'
|
|
})
|
|
|
|
expect(contents).toEqual({
|
|
original: '',
|
|
modified: '',
|
|
originalIsBinary: false,
|
|
modifiedIsBinary: false,
|
|
originalTooLarge: undefined,
|
|
modifiedTooLarge: true
|
|
})
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledOnce()
|
|
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
|
|
'repos/team/orca/contents/src/large.ts?ref=head-sha'
|
|
)
|
|
expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('does not fetch raw PR file contents while the repository core budget is blocked', async () => {
|
|
repositoryRateLimitGuardMock.mockReturnValue({
|
|
blocked: true,
|
|
remaining: 0,
|
|
limit: 5000,
|
|
resetAt: 1_800_000_000
|
|
})
|
|
|
|
await expect(
|
|
getPRFileContents({
|
|
repoPath: '/repo-root',
|
|
prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' },
|
|
prNumber: 7,
|
|
path: 'src/file.ts',
|
|
status: 'modified',
|
|
headSha: 'head-sha',
|
|
baseSha: 'base-sha'
|
|
})
|
|
).resolves.toMatchObject({ original: '', modified: '' })
|
|
|
|
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
|
expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|