mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Incorporates ghes-issue-host-support (ed6bb96ef): one hosted issue repository identity is resolved before the details fan-out so comments, timeline, participants, and mention lookups cannot drift across hosts, with SSH guards so unresolved issue/PR repositories never fall through to gh's default host. Co-authored-by: Orca <help@stably.ai>
1016 lines
35 KiB
TypeScript
1016 lines
35 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
type RateLimitGuardResult =
|
|
| { blocked: false }
|
|
| { blocked: true; remaining: number; limit: number; resetAt: number }
|
|
|
|
const {
|
|
ghExecFileAsyncMock,
|
|
getOwnerRepoMock,
|
|
getOwnerRepoForRemoteMock,
|
|
getEnterpriseGitHubRepoSlugMock,
|
|
getEnterpriseGitHubRepoSlugForRemoteMock,
|
|
getWorkItemMock,
|
|
getWorkItemByOwnerRepoMock,
|
|
getPRChecksMock,
|
|
getPRCommentsMock,
|
|
rateLimitGuardMock,
|
|
noteRateLimitSpendMock,
|
|
repositoryRateLimitGuardMock,
|
|
noteRepositoryRateLimitSpendMock,
|
|
ghRepoExecOptionsMock,
|
|
githubRepoContextMock,
|
|
acquireMock,
|
|
releaseMock
|
|
} = vi.hoisted(() => ({
|
|
ghExecFileAsyncMock: vi.fn(),
|
|
getOwnerRepoMock: vi.fn(),
|
|
getOwnerRepoForRemoteMock: vi.fn(),
|
|
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
|
getEnterpriseGitHubRepoSlugForRemoteMock: vi.fn(),
|
|
getWorkItemMock: vi.fn(),
|
|
getWorkItemByOwnerRepoMock: vi.fn(),
|
|
getPRChecksMock: vi.fn(),
|
|
getPRCommentsMock: vi.fn(),
|
|
rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })),
|
|
noteRateLimitSpendMock: vi.fn(),
|
|
repositoryRateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })),
|
|
noteRepositoryRateLimitSpendMock: vi.fn(),
|
|
ghRepoExecOptionsMock: vi.fn((context) =>
|
|
context.connectionId
|
|
? {}
|
|
: { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }
|
|
),
|
|
githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({
|
|
repoPath,
|
|
connectionId: connectionId ?? null,
|
|
...localGitOptions
|
|
})),
|
|
acquireMock: vi.fn(),
|
|
releaseMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./gh-utils', () => ({
|
|
ghExecFileAsync: ghExecFileAsyncMock,
|
|
getOwnerRepo: getOwnerRepoMock,
|
|
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
|
ghRepoExecOptions: ghRepoExecOptionsMock,
|
|
githubRepoContext: githubRepoContextMock,
|
|
acquire: acquireMock,
|
|
release: releaseMock
|
|
}))
|
|
|
|
vi.mock('./client', () => ({
|
|
getWorkItem: getWorkItemMock,
|
|
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
|
getPRChecks: getPRChecksMock,
|
|
getPRComments: getPRCommentsMock
|
|
}))
|
|
|
|
vi.mock('./github-enterprise-repository', () => ({
|
|
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock,
|
|
getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugForRemoteMock
|
|
}))
|
|
|
|
vi.mock('./rate-limit', () => ({
|
|
rateLimitGuard: rateLimitGuardMock,
|
|
noteRateLimitSpend: noteRateLimitSpendMock,
|
|
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()
|
|
})
|
|
|
|
describe('getWorkItemDetails', () => {
|
|
beforeEach(() => {
|
|
ghExecFileAsyncMock.mockReset()
|
|
getOwnerRepoMock.mockReset()
|
|
getOwnerRepoForRemoteMock.mockReset()
|
|
getEnterpriseGitHubRepoSlugMock.mockReset()
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugForRemoteMock.mockReset()
|
|
getEnterpriseGitHubRepoSlugForRemoteMock.mockResolvedValue(null)
|
|
getWorkItemMock.mockReset()
|
|
getWorkItemByOwnerRepoMock.mockReset()
|
|
getPRChecksMock.mockReset()
|
|
getPRCommentsMock.mockReset()
|
|
rateLimitGuardMock.mockReset()
|
|
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
|
noteRateLimitSpendMock.mockReset()
|
|
repositoryRateLimitGuardMock.mockReset()
|
|
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
|
|
noteRepositoryRateLimitSpendMock.mockReset()
|
|
ghRepoExecOptionsMock.mockClear()
|
|
githubRepoContextMock.mockClear()
|
|
acquireMock.mockReset()
|
|
releaseMock.mockReset()
|
|
acquireMock.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:923',
|
|
type: 'issue',
|
|
number: 923,
|
|
title: 'Use upstream issues',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/issues/923',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'issue-author'
|
|
})
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
const timelineEvents = [
|
|
{
|
|
id: 101,
|
|
event: 'assigned',
|
|
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
|
|
assignee: { login: 'assigned-user' },
|
|
created_at: '2026-04-01T01:00:00Z'
|
|
},
|
|
{
|
|
id: 102,
|
|
event: 'cross-referenced',
|
|
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
|
|
created_at: '2026-04-01T02:00:00Z',
|
|
source: {
|
|
issue: {
|
|
number: 6180,
|
|
title: 'Synthetic reference PR',
|
|
html_url: 'https://github.com/acme/widgets/pull/6180',
|
|
repository: { owner: { login: 'acme' }, name: 'widgets' },
|
|
pull_request: {}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
id: 103,
|
|
event: 'moved_columns_in_project',
|
|
actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' },
|
|
created_at: '2026-04-01T03:00:00Z',
|
|
previous_column_name: 'Doing',
|
|
project_column_name: 'Complete',
|
|
project: { name: 'Example Project' }
|
|
},
|
|
{
|
|
id: 104,
|
|
event: 'closed',
|
|
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
|
|
created_at: '2026-04-01T04:00:00Z',
|
|
state_reason: 'completed',
|
|
closer: {
|
|
number: 6180,
|
|
title: 'Synthetic reference PR',
|
|
html_url: 'https://github.com/acme/widgets/pull/6180',
|
|
repository: { owner: { login: 'acme' }, name: 'widgets' },
|
|
pull_request: {}
|
|
}
|
|
}
|
|
]
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
issue: {
|
|
body: 'Issue body',
|
|
assignees: { nodes: [{ login: 'assigned-user' }] },
|
|
participants: {
|
|
nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }]
|
|
},
|
|
comments: {
|
|
nodes: [
|
|
{
|
|
databaseId: 7,
|
|
body: 'first',
|
|
createdAt: '2026-04-01T00:00:00Z',
|
|
url: 'https://github.com/acme/widgets/issues/923#issuecomment-7',
|
|
author: { login: 'issue-author', avatarUrl: 'https://x/y' }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({
|
|
stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n')
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
|
|
|
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
|
expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api')
|
|
expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql')
|
|
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
|
|
'api',
|
|
'--cache',
|
|
'60s',
|
|
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
|
|
'--jq',
|
|
'.[] | @json'
|
|
])
|
|
expect(details?.body).toBe('Issue body')
|
|
expect(details?.assignees).toEqual(['assigned-user'])
|
|
expect(details?.comments).toHaveLength(1)
|
|
expect(details?.comments[0].id).toBe(7)
|
|
expect(details?.timelineItems).toMatchObject([
|
|
{ event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' },
|
|
{
|
|
event: 'cross-referenced',
|
|
source: {
|
|
type: 'pr',
|
|
number: 6180,
|
|
repository: 'acme/widgets'
|
|
}
|
|
},
|
|
{
|
|
event: 'moved_columns_in_project',
|
|
actor: 'github-project-automation',
|
|
previousColumnName: 'Doing',
|
|
columnName: 'Complete',
|
|
projectName: 'Example Project'
|
|
},
|
|
{
|
|
event: 'closed',
|
|
stateReason: 'completed',
|
|
closer: { type: 'pr', number: 6180 }
|
|
}
|
|
])
|
|
expect(details?.participants?.[0]?.login).toBe('issue-author')
|
|
})
|
|
|
|
it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => {
|
|
// Why: a GHE assignee who never commented is absent from `participants`, so
|
|
// the enrichment must also draw avatars from the `assignees` connection or
|
|
// item.assignees keeps the blank avatar `gh` returns.
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:924',
|
|
type: 'issue',
|
|
number: 924,
|
|
title: 'Assignee avatar',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/issues/924',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'issue-author',
|
|
assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }]
|
|
})
|
|
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
issue: {
|
|
body: 'Issue body',
|
|
assignees: {
|
|
nodes: [
|
|
{
|
|
login: 'ghe-assignee',
|
|
name: 'GHE Assignee',
|
|
avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee'
|
|
}
|
|
]
|
|
},
|
|
participants: { nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y' }] },
|
|
comments: { nodes: [] }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({ stdout: '' })
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 924, 'issue')
|
|
|
|
expect(details?.item.assignees?.[0]).toMatchObject({
|
|
login: 'ghe-assignee',
|
|
avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee'
|
|
})
|
|
})
|
|
|
|
it('caps issue timeline pagination by supported activity items', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:923',
|
|
type: 'issue',
|
|
number: 923,
|
|
title: 'Use upstream issues',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/issues/923',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'issue-author'
|
|
})
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
const makeTimelineEvent = (page: number, index: number, event: string): string =>
|
|
JSON.stringify({
|
|
id: `${page}:${index}`,
|
|
event,
|
|
actor: { login: 'issue-author', avatar_url: 'https://x/y' },
|
|
assignee: { login: `assignee-${page}-${index}` },
|
|
created_at: '2026-04-01T00:00:00Z'
|
|
})
|
|
const makeTimelinePage = (page: number, supportedCount: number): string =>
|
|
Array.from({ length: 100 }, (_, index) =>
|
|
makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed')
|
|
).join('\n')
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
issue: {
|
|
body: 'Issue body',
|
|
assignees: { nodes: [] },
|
|
participants: { nodes: [] },
|
|
comments: { nodes: [] }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) })
|
|
.mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) })
|
|
.mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) })
|
|
.mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) })
|
|
.mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) })
|
|
.mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) })
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7)
|
|
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
|
|
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1'
|
|
)
|
|
expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain(
|
|
'repos/acme/widgets/issues/923/timeline?per_page=100&page=2'
|
|
)
|
|
expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain(
|
|
'repos/acme/widgets/issues/923/timeline?per_page=100&page=3'
|
|
)
|
|
expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain(
|
|
'repos/acme/widgets/issues/923/timeline?per_page=100&page=6'
|
|
)
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.some((call) =>
|
|
call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7')
|
|
)
|
|
).toBe(false)
|
|
const timelineItems = details?.timelineItems
|
|
if (!timelineItems) {
|
|
throw new Error('Expected timeline items to be present')
|
|
}
|
|
expect(timelineItems).toHaveLength(300)
|
|
expect(timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
|
|
expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
|
|
})
|
|
|
|
it('falls back to REST + GraphQL when the collapsed issue query fails', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:923',
|
|
type: 'issue',
|
|
number: 923,
|
|
title: 'Use upstream issues',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/issues/923',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'issue-author'
|
|
})
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
// Collapsed GraphQL throws → fallback path picks up.
|
|
ghExecFileAsyncMock
|
|
.mockRejectedValueOnce(new Error('GraphQL error'))
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) })
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
data: { repository: { issue: { participants: { nodes: [] } } } }
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({ data: {} })
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
['api', '--cache', '60s', 'repos/acme/widgets/issues/923'],
|
|
{ cwd: '/repo-root', host: 'github.com' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'],
|
|
{ cwd: '/repo-root', host: 'github.com' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
4,
|
|
[
|
|
'api',
|
|
'--cache',
|
|
'60s',
|
|
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
|
|
'--jq',
|
|
'.[] | @json'
|
|
],
|
|
{ cwd: '/repo-root', host: 'github.com' }
|
|
)
|
|
expect(details?.body).toBe('Issue body')
|
|
})
|
|
|
|
it('skips optional GraphQL issue detail calls when the cached GraphQL budget is low', async () => {
|
|
// github.com repos share the singleton quota, so both the raw and
|
|
// repository-scoped guards report the same low-budget block.
|
|
rateLimitGuardMock.mockReturnValue({
|
|
blocked: true,
|
|
remaining: 3,
|
|
limit: 5000,
|
|
resetAt: 1_800_000_000
|
|
})
|
|
repositoryRateLimitGuardMock.mockReturnValue({
|
|
blocked: true,
|
|
remaining: 3,
|
|
limit: 5000,
|
|
resetAt: 1_800_000_000
|
|
})
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:923',
|
|
type: 'issue',
|
|
number: 923,
|
|
title: 'Use upstream issues',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/issues/923',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'issue-author'
|
|
})
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) })
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
|
expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false)
|
|
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
|
|
expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled()
|
|
expect(details?.body).toBe('Issue body')
|
|
expect(details?.participants).toEqual([])
|
|
})
|
|
|
|
it('uses SSH connection context for issue details without local cwd', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:923',
|
|
type: 'issue',
|
|
number: 923,
|
|
title: 'Use upstream issues',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/issues/923',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'issue-author'
|
|
})
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
issue: {
|
|
body: 'Remote issue body',
|
|
assignees: { nodes: [] },
|
|
participants: { nodes: [] },
|
|
comments: { nodes: [] }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
|
|
|
|
expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
|
|
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith(
|
|
'/home/tester/widgets',
|
|
'upstream',
|
|
'ssh-test-1',
|
|
{}
|
|
)
|
|
expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ host: 'github.com' })
|
|
expect(details?.body).toBe('Remote issue body')
|
|
})
|
|
|
|
it('uses the GitHub Enterprise host for SSH-backed issue work item details', async () => {
|
|
const enterpriseRepository = {
|
|
owner: 'team',
|
|
repo: 'orca',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'issue:7',
|
|
type: 'issue',
|
|
number: 7,
|
|
title: 'Enterprise issue',
|
|
state: 'open',
|
|
url: 'https://github.acme-corp.com/team/orca/issues/7',
|
|
labels: [],
|
|
updatedAt: '2026-07-16T00:00:00Z',
|
|
author: 'issue-author'
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepository)
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
|
if (query.includes('comments(first: 100)')) {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
issue: {
|
|
body: 'Enterprise issue body',
|
|
assignees: { nodes: [] },
|
|
participants: { nodes: [] },
|
|
comments: { nodes: [] }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
|
|
if (endpoint === 'repos/team/orca/issues/7/timeline?per_page=100&page=1') {
|
|
return { stdout: '' }
|
|
}
|
|
throw new Error(`unexpected gh call: ${args.join(' ')}`)
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')
|
|
|
|
expect(details?.body).toBe('Enterprise issue body')
|
|
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1')
|
|
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
|
expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', {})
|
|
expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalled()
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.every(
|
|
([, options]) => options?.host === 'github.acme-corp.com'
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('does not query the default host when an SSH issue repository is unresolved', async () => {
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
|
|
|
await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull()
|
|
|
|
expect(getWorkItemMock).not.toHaveBeenCalled()
|
|
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes local WSL PR detail fan-out through the selected distro', async () => {
|
|
const localGitOptions = { wslDistro: 'Ubuntu' }
|
|
rateLimitGuardMock.mockReturnValue({
|
|
blocked: true,
|
|
remaining: 0,
|
|
limit: 5000,
|
|
resetAt: 1_800_000_000
|
|
})
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'pr:42',
|
|
type: 'pr',
|
|
number: 42,
|
|
title: 'Review drawer WSL',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/pull/42',
|
|
labels: [],
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
author: 'pr-author'
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
getPRCommentsMock.mockResolvedValue([])
|
|
getPRChecksMock.mockResolvedValue([])
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const target = args.at(-1)
|
|
if (target === 'repos/acme/widgets/pulls/42') {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
body: 'PR body',
|
|
head: { sha: 'head-sha' },
|
|
base: { sha: 'base-sha' }
|
|
})
|
|
}
|
|
}
|
|
if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') {
|
|
return { stdout: '[]' }
|
|
}
|
|
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
|
if (query.includes('viewerViewedState')) {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
id: 'PR_kwDO123',
|
|
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: {} }) }
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 42, 'pr', null, localGitOptions)
|
|
|
|
expect(details?.body).toBe('PR body')
|
|
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions)
|
|
expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions)
|
|
expect(getPRCommentsMock).toHaveBeenCalledWith(
|
|
'/repo-root',
|
|
42,
|
|
{ prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } },
|
|
null,
|
|
localGitOptions
|
|
)
|
|
expect(getPRChecksMock).toHaveBeenCalledWith(
|
|
'/repo-root',
|
|
42,
|
|
'head-sha',
|
|
{ owner: 'acme', repo: 'widgets', host: 'github.com' },
|
|
undefined,
|
|
null,
|
|
localGitOptions
|
|
)
|
|
expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
|
|
true
|
|
)
|
|
expect(rateLimitGuardMock).not.toHaveBeenCalled()
|
|
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the GitHub Enterprise host for SSH-backed PR work item details', async () => {
|
|
getWorkItemByOwnerRepoMock.mockResolvedValueOnce({
|
|
id: 'pr:7',
|
|
type: 'pr',
|
|
number: 7,
|
|
title: 'Enterprise PR files',
|
|
state: 'open',
|
|
url: 'https://github.acme-corp.com/team/orca/pull/7',
|
|
labels: [],
|
|
updatedAt: '2026-07-16T00:00:00Z',
|
|
author: 'pr-author'
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
|
owner: 'team',
|
|
repo: 'orca',
|
|
host: 'github.acme-corp.com'
|
|
})
|
|
getPRCommentsMock.mockResolvedValue([])
|
|
getPRChecksMock.mockResolvedValue([])
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
|
|
if (endpoint === 'repos/team/orca/pulls/7') {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
body: 'Enterprise PR body',
|
|
head: { sha: 'head-sha' },
|
|
base: { sha: 'base-sha' }
|
|
})
|
|
}
|
|
}
|
|
if (endpoint === 'repos/team/orca/pulls/7/files?per_page=100') {
|
|
return {
|
|
stdout: JSON.stringify([
|
|
{
|
|
filename: 'src/enterprise.ts',
|
|
status: 'modified',
|
|
additions: 2,
|
|
deletions: 1,
|
|
changes: 3,
|
|
patch: '@@ -1 +1 @@'
|
|
}
|
|
])
|
|
}
|
|
}
|
|
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
|
if (query.includes('viewerViewedState')) {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
id: 'PR_enterprise',
|
|
files: {
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
|
nodes: [{ path: 'src/enterprise.ts', viewerViewedState: 'VIEWED' }]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
if (query.includes('participants(first: 100)')) {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
data: { repository: { pullRequest: { participants: { nodes: [] } } } }
|
|
})
|
|
}
|
|
}
|
|
throw new Error(`unexpected gh call: ${args.join(' ')}`)
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')
|
|
|
|
expect(details?.body).toBe('Enterprise PR body')
|
|
expect(details?.headSha).toBe('head-sha')
|
|
expect(details?.baseSha).toBe('base-sha')
|
|
expect(details?.filesUnavailable).toBe(false)
|
|
expect(details?.files).toEqual([
|
|
{
|
|
path: 'src/enterprise.ts',
|
|
oldPath: undefined,
|
|
status: 'modified',
|
|
additions: 2,
|
|
deletions: 1,
|
|
isBinary: false,
|
|
reviewCommentLineNumbers: [],
|
|
viewerViewedState: 'VIEWED'
|
|
}
|
|
])
|
|
expect(getWorkItemMock).not.toHaveBeenCalled()
|
|
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
|
|
'/remote/repo',
|
|
{ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' },
|
|
7,
|
|
'pr',
|
|
'ssh-1'
|
|
)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
|
expect(getPRCommentsMock).toHaveBeenCalledWith(
|
|
'/remote/repo',
|
|
7,
|
|
{ prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } },
|
|
'ssh-1'
|
|
)
|
|
expect(getPRChecksMock).toHaveBeenCalledWith(
|
|
'/remote/repo',
|
|
7,
|
|
'head-sha',
|
|
{ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' },
|
|
undefined,
|
|
'ssh-1'
|
|
)
|
|
const apiCalls = ghExecFileAsyncMock.mock.calls
|
|
.map(([args]) => args as string[])
|
|
.filter((args) => args[0] === 'api')
|
|
expect(apiCalls.length).toBeGreaterThan(0)
|
|
expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true)
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.every(
|
|
([, options]) => options?.host === 'github.acme-corp.com'
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('does not fall back to the default host after an Enterprise PR lookup fails', async () => {
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
|
owner: 'team',
|
|
repo: 'orca',
|
|
host: 'github.acme-corp.com'
|
|
})
|
|
getWorkItemByOwnerRepoMock.mockResolvedValue(null)
|
|
getWorkItemMock.mockResolvedValue({
|
|
id: 'pr:7',
|
|
type: 'pr',
|
|
number: 7,
|
|
title: 'Wrong github.com PR',
|
|
state: 'open',
|
|
url: 'https://github.com/team/orca/pull/7',
|
|
labels: [],
|
|
updatedAt: '2026-07-16T00:00:00Z',
|
|
author: 'wrong-author'
|
|
})
|
|
|
|
await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull()
|
|
|
|
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledTimes(1)
|
|
expect(getWorkItemMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not query the default host when an SSH PR repository is unresolved', async () => {
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
|
|
|
await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull()
|
|
|
|
expect(getWorkItemMock).not.toHaveBeenCalled()
|
|
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the GitHub Enterprise host when fetching PR file contents', async () => {
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
|
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',
|
|
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)
|
|
})
|
|
|
|
// 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 PR file fetch fails but leaves the PR empty otherwise intact', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'pr:8305',
|
|
type: 'pr',
|
|
number: 8305,
|
|
title: 'Files fetch fails',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/pull/8305',
|
|
labels: [],
|
|
updatedAt: '2026-07-11T00:00:00Z',
|
|
author: 'pr-author'
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
getPRCommentsMock.mockResolvedValue([])
|
|
getPRChecksMock.mockResolvedValue([])
|
|
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 { stdout: JSON.stringify({ data: {} }) }
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 8305, 'pr')
|
|
|
|
expect(details?.filesUnavailable).toBe(true)
|
|
expect(details?.files).toBeUndefined()
|
|
})
|
|
|
|
it('treats an empty file list as a genuinely empty PR, not an unavailable one', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'pr:8306',
|
|
type: 'pr',
|
|
number: 8306,
|
|
title: 'Empty PR',
|
|
state: 'open',
|
|
url: 'https://github.com/acme/widgets/pull/8306',
|
|
labels: [],
|
|
updatedAt: '2026-07-11T00:00:00Z',
|
|
author: 'pr-author'
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
getPRCommentsMock.mockResolvedValue([])
|
|
getPRChecksMock.mockResolvedValue([])
|
|
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 { stdout: JSON.stringify({ data: {} }) }
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 8306, 'pr')
|
|
|
|
expect(details?.filesUnavailable).toBe(false)
|
|
expect(details?.files).toEqual([])
|
|
})
|
|
|
|
// Why: `gh pr view` omits avatar_url, so the login-based github.com URL 404s on
|
|
// GHE. getWorkItemDetails must resolve author/reviewer/assignee avatars via the
|
|
// GraphQL user(login:) batch and stamp them onto the returned item. See #8784.
|
|
it('enriches PR author, reviewer, and assignee avatars from the GraphQL user lookup', async () => {
|
|
getWorkItemMock.mockResolvedValueOnce({
|
|
id: 'pr:1102',
|
|
type: 'pr',
|
|
number: 1102,
|
|
title: 'Enterprise PR',
|
|
state: 'open',
|
|
url: 'https://ghe.example.com/acme/widgets/pull/1102',
|
|
labels: [],
|
|
updatedAt: '2026-07-11T00:00:00Z',
|
|
author: 'seah',
|
|
reviewRequests: [{ login: 'ludi', name: null, avatarUrl: '' }],
|
|
latestReviews: [{ login: 'inho', state: 'APPROVED', avatarUrl: '' }],
|
|
// A default `u/0` placeholder must be replaced by the resolved avatar.
|
|
assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }]
|
|
})
|
|
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
|
getPRCommentsMock.mockResolvedValue([])
|
|
getPRChecksMock.mockResolvedValue([])
|
|
const avatars: Record<string, string> = {
|
|
seah: 'https://avatars.example.com/u/1?v=4',
|
|
ludi: 'https://avatars.example.com/u/2?v=4',
|
|
inho: 'https://avatars.example.com/u/3?v=4'
|
|
}
|
|
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
const target = args.at(-1)
|
|
if (target === 'repos/acme/widgets/pulls/1102') {
|
|
return {
|
|
stdout: JSON.stringify({ body: 'PR body', head: { sha: 'h' }, base: { sha: 'b' } })
|
|
}
|
|
}
|
|
if (target === 'repos/acme/widgets/pulls/1102/files?per_page=100') {
|
|
return { stdout: '[]' }
|
|
}
|
|
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
|
if (query.includes('user(login:')) {
|
|
// Return the aliased users the batch asked for, keyed by their login.
|
|
const data: Record<string, { login: string; name: null; avatarUrl: string }> = {}
|
|
let index = 0
|
|
for (const login of Object.keys(avatars)) {
|
|
if (query.includes(`user(login: "${login}")`)) {
|
|
data[`u${index}`] = { login, name: null, avatarUrl: avatars[login] }
|
|
index += 1
|
|
}
|
|
}
|
|
return { stdout: JSON.stringify({ data }) }
|
|
}
|
|
return { stdout: JSON.stringify({ data: {} }) }
|
|
})
|
|
|
|
const details = await getWorkItemDetails('/repo-root', 1102, 'pr')
|
|
|
|
expect(details?.item.authorAvatarUrl).toBe(avatars.seah)
|
|
expect(details?.item.reviewRequests?.[0]?.avatarUrl).toBe(avatars.ludi)
|
|
expect(details?.item.latestReviews?.[0]?.avatarUrl).toBe(avatars.inho)
|
|
// The default u/0 placeholder is overridden by the resolved avatar.
|
|
expect(details?.item.assignees?.[0]?.avatarUrl).toBe(avatars.seah)
|
|
})
|
|
})
|