mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Keep partial check details from replacing complete PR status (#38514)
* Keep PR check status tied to complete observations * Correct checks presentation types and count import * Preserve localized empty states and declared runtime support * Correct discovery fixture stack types * Omit empty failed-check subtitles * Preserve check identity through refresh and row publication * Withhold row identity for malformed check errors * Limit local capability wakes to local task sources * Correct PR check regression fixtures * Preserve browser APIs in PR check regression fixture * Settle PR check adoption after failed detail reads * Use the parsed PR check detail type during refresh * Preserve PR metadata across checks discovery and committed query lifetimes * Keep complete checks proof separate from legacy publication policy * Keep PR lookup fixtures independent from complete checks collection * Keep complete-check fixtures within lint contracts * test: align PR recordings with source check discovery
This commit is contained in:
@@ -47,10 +47,12 @@ import { resolveReviewThread } from './client/update/resolve-review-thread'
|
||||
import { setPRAutoMerge } from './client/merge/pr-auto-merge'
|
||||
import { updatePRTitle } from './client/update/pr-details'
|
||||
import { _getMergeQueueCacheSizeForTests } from './client/detect/repository-merge-metadata-cache'
|
||||
import { resetGraphQLRateLimitGuardMocks } from './client-test-harness'
|
||||
import {
|
||||
installIndependentCheckFixtures,
|
||||
resetGraphQLRateLimitGuardMocks
|
||||
} from './client-test-harness'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
gitExecFileAsyncMock,
|
||||
@@ -59,15 +61,20 @@ const {
|
||||
acquireMock
|
||||
} = clientMocks
|
||||
|
||||
const metadataRequests = vi.fn()
|
||||
const unexpectedChecks: string[][] = []
|
||||
afterEach(() => expect(unexpectedChecks).toEqual([]))
|
||||
|
||||
describe('GitHub GraphQL rate-limit guard', () => {
|
||||
beforeEach(() => {
|
||||
resetGraphQLRateLimitGuardMocks(clientMocks)
|
||||
installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks)
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('uses explicit PR repo for merge and title mutations', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 7,
|
||||
@@ -111,7 +118,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['api', 'repos/stablyai/orca/pulls/7', '--include'],
|
||||
{
|
||||
@@ -119,7 +126,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
host: 'github.com'
|
||||
}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
'pr',
|
||||
@@ -128,11 +135,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
'--repo',
|
||||
'stablyai/orca',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root', host: 'github.com' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({
|
||||
@@ -141,7 +148,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
host: 'github.com'
|
||||
})
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
[
|
||||
'api',
|
||||
@@ -156,7 +163,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
|
||||
it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
|
||||
@@ -178,19 +185,19 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['api', 'repos/stablyai/orca/pulls/7', '--include'],
|
||||
{
|
||||
host: 'github.com'
|
||||
}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'],
|
||||
{ host: 'github.com' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.arrayContaining([
|
||||
'api',
|
||||
@@ -207,7 +214,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
host: 'github.com'
|
||||
})
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({
|
||||
@@ -215,11 +222,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
host: 'github.com'
|
||||
})
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
|
||||
expect(metadataRequests.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('enables auto-merge without invoking the direct merge command', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
|
||||
@@ -235,12 +242,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
metadataRequests.mock.calls.some((call) =>
|
||||
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some(
|
||||
metadataRequests.mock.calls.some(
|
||||
(call) =>
|
||||
call[0][0] === 'pr' && call[0][1] === 'merge' && (call[0] as string[]).includes('--auto')
|
||||
)
|
||||
@@ -248,7 +255,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
|
||||
it('rejects auto-merge for GitHub-registered stacks', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 202,
|
||||
title: 'Stack API',
|
||||
@@ -274,11 +281,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
ok: false,
|
||||
error: 'GitHub does not support auto-merge for stacked pull requests.'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('translates the GitHub clean-status rejection into an actionable message', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
|
||||
@@ -300,7 +307,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
it('preserves a provider rejection when eligibility changes before enabling auto-merge', async () => {
|
||||
const providerError =
|
||||
'gh: Pull request Pull request is in unstable status (enablePullRequestAutoMerge)'
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
|
||||
@@ -318,12 +325,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
error: `Failed to enable auto-merge for pull request: ${providerError}`
|
||||
})
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
metadataRequests.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('names a failed disable-auto-merge operation', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('gh: pull request is already closed'))
|
||||
metadataRequests.mockRejectedValueOnce(new Error('gh: pull request is already closed'))
|
||||
await expect(
|
||||
setPRAutoMerge('/repo-root', 7, false, 'squash', undefined, {
|
||||
owner: 'stablyai',
|
||||
@@ -340,7 +347,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
|
||||
it('preserves REST merge eligibility and an existing auto-merge request in PR details', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockRejectedValueOnce(new Error('GraphQL unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
@@ -377,7 +384,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
|
||||
it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' })
|
||||
@@ -395,12 +402,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']),
|
||||
{ cwd: '/repo-root', host: 'github.com' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({
|
||||
@@ -410,14 +417,14 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
metadataRequests.mock.calls.some((call) =>
|
||||
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('enqueues instead of gh pr merge --auto when a required merge queue disallows auto-merge', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' })
|
||||
@@ -437,8 +444,8 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(4)
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
[
|
||||
'api',
|
||||
@@ -459,7 +466,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
|
||||
it('blocks direct merge when GitHub reports required approval', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
@@ -491,8 +498,8 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
error: 'This pull request requires review approval before it can be merged.'
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
||||
expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toContain('graphql')
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(3)
|
||||
expect(metadataRequests.mock.calls[2]?.[0]).toContain('graphql')
|
||||
})
|
||||
|
||||
it('detects merge queues once per base branch and blocks direct merges', async () => {
|
||||
@@ -512,7 +519,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -536,13 +543,13 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' })
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql'))
|
||||
).toHaveLength(1)
|
||||
expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toEqual(
|
||||
expect(metadataRequests.mock.calls.filter((call) => call[0].includes('graphql'))).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(metadataRequests.mock.calls[2]?.[0]).toEqual(
|
||||
expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true'])
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).not.toContain('-F')
|
||||
expect(metadataRequests.mock.calls[2]?.[0]).not.toContain('-F')
|
||||
})
|
||||
|
||||
it('caches unknown merge queue probes after GraphQL failures', async () => {
|
||||
@@ -563,7 +570,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
|
||||
.mockRejectedValueOnce(new Error('network is down'))
|
||||
.mockResolvedValueOnce({ stdout: '{}' })
|
||||
@@ -577,9 +584,9 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
mergeQueueRequired: null
|
||||
})
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql'))
|
||||
).toHaveLength(1)
|
||||
expect(metadataRequests.mock.calls.filter((call) => call[0].includes('graphql'))).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses repository metadata between PR details and sidebar refreshes', async () => {
|
||||
@@ -599,7 +606,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
baseRefName: 'main',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
metadataRequests.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('graphql')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
@@ -618,15 +625,13 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
autoMergeAllowed: true
|
||||
})
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('graphql'))
|
||||
).toHaveLength(1)
|
||||
expect(metadataRequests.mock.calls.filter(([args]) => args.includes('graphql'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds merge metadata cache entries across many base branches', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
let prViewCount = 0
|
||||
ghExecFileAsyncMock.mockImplementation(async (args) => {
|
||||
metadataRequests.mockImplementation(async (args) => {
|
||||
if (args.includes('graphql')) {
|
||||
return { stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) }
|
||||
}
|
||||
@@ -678,7 +683,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
baseRefName: 'main',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
ghExecFileAsyncMock.mockImplementation(async (args, options) => {
|
||||
metadataRequests.mockImplementation(async (args, options) => {
|
||||
if (args.includes('graphql')) {
|
||||
const enterprise = options?.host === 'github.acme-corp.com'
|
||||
return {
|
||||
@@ -710,7 +715,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
|
||||
expect(githubDotCom?.autoMergeAllowed).toBe(true)
|
||||
expect(enterprise?.autoMergeAllowed).toBe(false)
|
||||
const graphqlCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('graphql'))
|
||||
const graphqlCalls = metadataRequests.mock.calls.filter(([args]) => args.includes('graphql'))
|
||||
expect(graphqlCalls).toHaveLength(2)
|
||||
expect(graphqlCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' }))
|
||||
})
|
||||
@@ -731,11 +736,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(acquireMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(metadataRequests).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns conflicting file details instead of running gh merge when PR is dirty', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
@@ -775,11 +780,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
'- src/conflict.ts'
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not run merge conflict preflight for SSH-backed repos', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
@@ -806,15 +811,15 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(3)
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
|
||||
})
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
|
||||
expect(metadataRequests.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -828,7 +833,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
|
||||
await expect(resolveReviewThread('/repo-root', 'thread-1', true)).resolves.toBe(false)
|
||||
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(metadataRequests).not.toHaveBeenCalled()
|
||||
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as GitExecFileModule from '../git/command-runner/git-exec-file'
|
||||
import type * as GhExecFileModule from '../git/command-runner/gh-exec-file'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository'
|
||||
|
||||
@@ -39,24 +39,28 @@ vi.mock('./github-api-repository', async (importOriginal) =>
|
||||
|
||||
import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
|
||||
import { getPRForBranch } from './client/lookup/get-pr-for-branch'
|
||||
import { resetPRForBranchMocks } from './client-test-harness'
|
||||
import { installIndependentCheckFixtures, resetPRForBranchMocks } from './client-test-harness'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
gitExecFileAsyncMock,
|
||||
rateLimitGuardMock
|
||||
} = clientMocks
|
||||
|
||||
const metadataRequests = vi.fn()
|
||||
const unexpectedChecks: string[][] = []
|
||||
afterEach(() => expect(unexpectedChecks).toEqual([]))
|
||||
|
||||
describe('getPRForBranch', () => {
|
||||
beforeEach(() => {
|
||||
resetPRForBranchMocks(clientMocks)
|
||||
installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks)
|
||||
})
|
||||
|
||||
it('ignores merged PRs discovered only by branch lookup when the branch moved on', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
@@ -101,7 +105,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('shows a merged branch PR when it still matches the current HEAD', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
@@ -154,7 +158,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const mockMergedBranchPRLookupBehindHead = (prNumber = 6011): void => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
@@ -195,13 +199,13 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('shows a merged branch PR when the worktree head is one of its own commits', async () => {
|
||||
mockMergedBranchPRLookupBehindHead()
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ number: 6011 }, { number: 42 }])
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'],
|
||||
expect.anything()
|
||||
@@ -216,25 +220,25 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('keeps hiding a merged branch PR when the head belongs to a different PR (reused branch)', async () => {
|
||||
mockMergedBranchPRLookupBehindHead()
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ number: 42 }])
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
|
||||
expect(pr).toBeNull()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(3)
|
||||
|
||||
mockMergedBranchPRLookupBehindHead()
|
||||
const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
|
||||
expect(second).toBeNull()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('keeps hiding a merged branch PR when the commit membership probe fails', async () => {
|
||||
mockMergedBranchPRLookupBehindHead()
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found'))
|
||||
metadataRequests.mockRejectedValueOnce(new Error('HTTP 422: No commit found'))
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
|
||||
@@ -252,28 +256,28 @@ describe('getPRForBranch', () => {
|
||||
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
|
||||
expect(pr).toBeNull()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses the cached membership answer instead of re-querying per poll', async () => {
|
||||
mockMergedBranchPRLookupBehindHead()
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ number: 6011 }])
|
||||
})
|
||||
const first = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
expect(first).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(3)
|
||||
|
||||
mockMergedBranchPRLookupBehindHead()
|
||||
const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
|
||||
|
||||
expect(second).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('uses the caller-supplied worktree head for the membership probe without shelling out', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
@@ -342,7 +346,7 @@ describe('getPRForBranch', () => {
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: prNumber,
|
||||
title: 'Merged linked PR',
|
||||
@@ -362,7 +366,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('stamps confirmedContainedHeadOid for a linked merged PR when HEAD is its commit', async () => {
|
||||
mockMergedLinkedPRLookup()
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ number: 7447 }])
|
||||
})
|
||||
|
||||
@@ -385,7 +389,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('stamps headDivergedFromMergedPRAtOid for a linked merged PR with a definite miss', async () => {
|
||||
mockMergedLinkedPRLookup()
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ number: 42 }])
|
||||
})
|
||||
|
||||
@@ -407,7 +411,7 @@ describe('getPRForBranch', () => {
|
||||
mockMergedLinkedPRLookup()
|
||||
// A short second page proves absence after a full first page.
|
||||
const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index }))
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) })
|
||||
|
||||
@@ -426,7 +430,7 @@ describe('getPRForBranch', () => {
|
||||
// Full pages through the cap cannot prove absence, so preserve the durable link.
|
||||
const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index }))
|
||||
for (let page = 0; page < 5; page += 1) {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) })
|
||||
metadataRequests.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) })
|
||||
}
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, {
|
||||
@@ -442,7 +446,7 @@ describe('getPRForBranch', () => {
|
||||
it('stamps linked merged divergence via the PR url when no repo candidates resolve', async () => {
|
||||
// Without resolved candidates, derive the membership-probe repository from the PR URL.
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 7447,
|
||||
@@ -469,7 +473,7 @@ describe('getPRForBranch', () => {
|
||||
kind: 'found',
|
||||
pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' }
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledWith(
|
||||
['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'],
|
||||
expect.anything()
|
||||
)
|
||||
@@ -491,12 +495,12 @@ describe('getPRForBranch', () => {
|
||||
expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe(
|
||||
undefined
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('leaves linked merged divergence unset when the membership probe throws', async () => {
|
||||
mockMergedLinkedPRLookup()
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found'))
|
||||
metadataRequests.mockRejectedValueOnce(new Error('HTTP 422: No commit found'))
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, {
|
||||
currentHeadOid: 'bbbb2222bbbb2222'
|
||||
@@ -510,7 +514,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('leaves linked merged divergence unset when the membership probe returns a non-array payload', async () => {
|
||||
mockMergedLinkedPRLookup()
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ message: 'Server Error' })
|
||||
})
|
||||
|
||||
@@ -533,6 +537,6 @@ describe('getPRForBranch', () => {
|
||||
expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe(
|
||||
undefined
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { completePRCheckGraphQLResponse } from '../../shared/github/complete-pr-check-test-fixture'
|
||||
import type * as GitExecFileModule from '../git/command-runner/git-exec-file'
|
||||
import type * as GhExecFileModule from '../git/command-runner/gh-exec-file'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -78,6 +79,16 @@ describe('getPRForBranch', () => {
|
||||
statusCheckRollup: [{ status: 'COMPLETED', conclusion: 'CANCELLED' }]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce(
|
||||
completePRCheckGraphQLResponse(7, 'head-oid', [
|
||||
{
|
||||
__typename: 'CheckRun',
|
||||
status: 'COMPLETED',
|
||||
conclusion: 'CANCELLED',
|
||||
checkSuite: { databaseId: 1 }
|
||||
}
|
||||
])
|
||||
)
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
||||
expect(ghExecFileAsyncMock.mock.calls.slice(0, 2).map(([args]) => args.slice(0, 2))).toEqual([
|
||||
['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10'],
|
||||
@@ -130,7 +141,12 @@ describe('getPRForBranch', () => {
|
||||
['pr', 'list'],
|
||||
['pr', 'view']
|
||||
])
|
||||
expect(pr).toMatchObject({ number: 7, checksStatus: 'failure' })
|
||||
expect(pr).toMatchObject({
|
||||
number: 7,
|
||||
title: 'Partial check page',
|
||||
headSha: 'head-oid',
|
||||
checksStatus: 'neutral'
|
||||
})
|
||||
expect(pr?.checksSummary).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -549,7 +565,7 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock('./rate-limit', () => ({
|
||||
spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock
|
||||
}))
|
||||
|
||||
import { getPRChecks } from './client/check/get-pr-checks'
|
||||
import { getPRChecks, getPRChecksViaRestFallback } from './client/check/get-pr-checks'
|
||||
import { rerunPRChecks } from './client/check/rerun-pr-checks'
|
||||
import { _resetOwnerRepoCache } from './gh-utils'
|
||||
|
||||
@@ -97,11 +97,13 @@ beforeEach(() => {
|
||||
function graphQLChecksResponse({
|
||||
contexts = [],
|
||||
checkSuites = [],
|
||||
headRefOid = 'head-oid'
|
||||
headRefOid = 'head-oid',
|
||||
commitOid = headRefOid
|
||||
}: {
|
||||
contexts?: unknown[]
|
||||
checkSuites?: unknown[]
|
||||
headRefOid?: string
|
||||
commitOid?: string
|
||||
} = {}): { stdout: string } {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
@@ -113,6 +115,7 @@ function graphQLChecksResponse({
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: commitOid,
|
||||
statusCheckRollup: { contexts: { nodes: contexts } },
|
||||
checkSuites: { nodes: checkSuites }
|
||||
}
|
||||
@@ -451,6 +454,7 @@ describe('getPRChecks', () => {
|
||||
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
total_count: 1,
|
||||
check_runs: [
|
||||
{
|
||||
id: 88,
|
||||
@@ -465,6 +469,7 @@ describe('getPRChecks', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
total_count: 1,
|
||||
statuses: [
|
||||
{
|
||||
context: 'continuous-integration/jenkins/pr-merge',
|
||||
@@ -474,19 +479,24 @@ describe('getPRChecks', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ total_count: 0, check_suites: [] }) })
|
||||
|
||||
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
|
||||
|
||||
expectGraphQLRollupCall()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'],
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
'repos/acme/widgets/commits/head-oid/check-runs?per_page=100&page=1'
|
||||
],
|
||||
{ cwd: '/repo-root', host: 'github.com' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100'],
|
||||
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100&page=1'],
|
||||
{ cwd: '/repo-root', host: 'github.com' }
|
||||
)
|
||||
expect(checks).toEqual([
|
||||
@@ -559,7 +569,7 @@ describe('getPRChecks', () => {
|
||||
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }])
|
||||
})
|
||||
|
||||
const checks = await getPRChecks('/repo-root', 42, 'stale-head')
|
||||
const checks = await getPRChecks('/repo-root', 42, 'a'.repeat(40))
|
||||
|
||||
expectGraphQLRollupCall()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
@@ -762,3 +772,64 @@ describe('getPRChecks', () => {
|
||||
await expect(getPRChecks('/repo-root', 42)).rejects.toThrow('rate limited')
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the actual matching GraphQL commit identity rather than the requested head', async () => {
|
||||
vi.clearAllMocks()
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce(
|
||||
graphQLChecksResponse({
|
||||
headRefOid: 'b'.repeat(40),
|
||||
contexts: [{ __typename: 'StatusContext', context: 'Build', state: 'SUCCESS' }]
|
||||
})
|
||||
)
|
||||
const checks = await getPRChecks('/repo-root', 42, 'a'.repeat(40))
|
||||
expect(JSON.parse(JSON.stringify(checks))).toEqual([
|
||||
{
|
||||
name: 'Build',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
url: null,
|
||||
observedHeadSha: 'b'.repeat(40)
|
||||
}
|
||||
])
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce(
|
||||
graphQLChecksResponse({
|
||||
headRefOid: 'a'.repeat(40),
|
||||
commitOid: 'b'.repeat(40),
|
||||
contexts: [{ __typename: 'StatusContext', context: 'Build', state: 'SUCCESS' }]
|
||||
})
|
||||
)
|
||||
expect((await getPRChecks('/repo-root', 42, 'a'.repeat(40)))[0].observedHeadSha).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries immutable REST endpoint identity through useful incomplete enrichment', async () => {
|
||||
vi.clearAllMocks()
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
total_count: 1,
|
||||
check_runs: [
|
||||
{
|
||||
id: 88,
|
||||
name: 'Build',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
html_url: '',
|
||||
details_url: null
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('statuses unavailable'))
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ total_count: 0, check_suites: [] }) })
|
||||
const checks = await getPRChecksViaRestFallback(
|
||||
{ owner: 'acme', repo: 'widgets', host: 'github.com' },
|
||||
'a'.repeat(40),
|
||||
{ cwd: '/repo-root' },
|
||||
true
|
||||
)
|
||||
expect(checks).toMatchObject([{ name: 'Build', observedHeadSha: 'a'.repeat(40) }])
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
|
||||
`repos/acme/widgets/commits/${'a'.repeat(40)}/check-runs?per_page=100&page=1`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as GitExecFileModule from '../git/command-runner/git-exec-file'
|
||||
import type * as GhExecFileModule from '../git/command-runner/gh-exec-file'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository'
|
||||
|
||||
@@ -41,23 +41,23 @@ vi.mock('./github-api-repository', async (importOriginal) =>
|
||||
)
|
||||
|
||||
import { getPRForBranch } from './client/lookup/get-pr-for-branch'
|
||||
import { resetPRForBranchMocks } from './client-test-harness'
|
||||
import { installIndependentCheckFixtures, resetPRForBranchMocks } from './client-test-harness'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
gitExecFileAsyncMock
|
||||
} = clientMocks
|
||||
const { getOwnerRepoMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock } = clientMocks
|
||||
|
||||
const metadataRequests = vi.fn()
|
||||
const unexpectedChecks: string[][] = []
|
||||
afterEach(() => expect(unexpectedChecks).toEqual([]))
|
||||
|
||||
describe('getPRForBranch', () => {
|
||||
beforeEach(() => {
|
||||
resetPRForBranchMocks(clientMocks)
|
||||
installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks)
|
||||
})
|
||||
|
||||
it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -92,7 +92,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('routes local WSL branch status and conflict summary git probes through the selected distro', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -123,7 +123,7 @@ describe('getPRForBranch', () => {
|
||||
expect(resolvePRRepositoryCandidatesMock).toHaveBeenCalledWith('/repo-root', null, {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
cwd: '/repo-root',
|
||||
@@ -154,7 +154,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('treats GitHub DIRTY merge state as conflicting when mergeable is still unknown', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 42,
|
||||
@@ -191,7 +191,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('omits conflict summaries for SSH-backed repos', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -216,7 +216,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -248,7 +248,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('falls back to the legacy merge-tree invocation when Git lacks --merge-base', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -337,7 +337,7 @@ describe('getPRForBranch', () => {
|
||||
// Change head OID to bypass the result cache while retaining the learned Git capability.
|
||||
const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } }
|
||||
const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' }
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -376,7 +376,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('does not retry legacy merge-tree for older Git failures unrelated to --merge-base', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -424,7 +424,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('marks the conflict summary as locally clean when GitHub reports dirty but merge-tree has no conflicted files', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
@@ -461,7 +461,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type * as GitExecFileModule from '../git/command-runner/git-exec-file'
|
||||
import type * as GhExecFileModule from '../git/command-runner/gh-exec-file'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository'
|
||||
|
||||
@@ -38,18 +38,18 @@ vi.mock('./github-api-repository', async (importOriginal) =>
|
||||
)
|
||||
|
||||
import { getPRForBranch } from './client/lookup/get-pr-for-branch'
|
||||
import { resetPRForBranchMocks } from './client-test-harness'
|
||||
import { installIndependentCheckFixtures, resetPRForBranchMocks } from './client-test-harness'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
gitExecFileAsyncMock
|
||||
} = clientMocks
|
||||
const { getOwnerRepoMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock } = clientMocks
|
||||
|
||||
const metadataRequests = vi.fn()
|
||||
const unexpectedChecks: string[][] = []
|
||||
afterEach(() => expect(unexpectedChecks).toEqual([]))
|
||||
|
||||
describe('getPRForBranch', () => {
|
||||
beforeEach(() => {
|
||||
resetPRForBranchMocks(clientMocks)
|
||||
installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks)
|
||||
})
|
||||
|
||||
it('looks up a linked PR number across PR repo candidates', async () => {
|
||||
@@ -61,28 +61,26 @@ describe('getPRForBranch', () => {
|
||||
headRepo: { owner: 'fork', repo: 'orca' }
|
||||
})
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
title: 'Linked fork PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/fork/orca/pull/99',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'linked-head-oid'
|
||||
})
|
||||
metadataRequests.mockRejectedValueOnce(new Error('HTTP 404: Not Found')).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
title: 'Linked fork PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/fork/orca/pull/99',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'linked-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
@@ -91,11 +89,11 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'stablyai/orca',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
'pr',
|
||||
@@ -104,7 +102,7 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'fork/orca',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -114,7 +112,7 @@ describe('getPRForBranch', () => {
|
||||
it('prefers exact linked PR lookup when the repo identity is known', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
title: 'Linked PR',
|
||||
@@ -133,9 +131,9 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledWith(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
@@ -143,7 +141,7 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -157,7 +155,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('caches exact REST stack probes across linked PR refreshes', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
metadataRequests.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'pr') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
@@ -190,16 +188,14 @@ describe('getPRForBranch', () => {
|
||||
await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1')
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'api' && args[1]?.includes('/99')
|
||||
)
|
||||
metadataRequests.mock.calls.filter(([args]) => args[0] === 'api' && args[1]?.includes('/99'))
|
||||
).toHaveLength(2)
|
||||
expect(ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3)
|
||||
expect(metadataRequests.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('caches failed REST stack probes across linked PR refreshes', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
metadataRequests.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'pr') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
@@ -224,15 +220,13 @@ describe('getPRForBranch', () => {
|
||||
await getPRForBranch('/repo-root', 'feature', 99)
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'api' && args[1]?.includes('/99')
|
||||
)
|
||||
metadataRequests.mock.calls.filter(([args]) => args[0] === 'api' && args[1]?.includes('/99'))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hydrates repository merge method settings for exact PR lookups', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
@@ -279,7 +273,7 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
expect(pr?.mergeQueueRequired).toBe(false)
|
||||
expect(pr?.autoMergeAllowed).toBe(true)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.arrayContaining([
|
||||
'api',
|
||||
@@ -298,7 +292,7 @@ describe('getPRForBranch', () => {
|
||||
it('isolates viewer-dependent merge metadata across SSH connections', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
let metadataProbe = 0
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
metadataRequests.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('graphql')) {
|
||||
metadataProbe += 1
|
||||
return {
|
||||
@@ -346,7 +340,7 @@ describe('getPRForBranch', () => {
|
||||
it('treats linked PR metadata as authoritative even when the branch head differs', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
@@ -384,36 +378,34 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(pr?.number).toBe(99)
|
||||
})
|
||||
|
||||
it('does not fall back to branch discovery when linked PR metadata is stale', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Branch PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
])
|
||||
})
|
||||
metadataRequests.mockRejectedValueOnce(new Error('HTTP 404: Not Found')).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Branch PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
@@ -422,17 +414,17 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(1)
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('returns no PR when linked PR REST fallback also misses', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockRejectedValueOnce(new Error('GraphQL: could not resolve to PullRequest'))
|
||||
.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
|
||||
.mockResolvedValueOnce({
|
||||
@@ -456,20 +448,20 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', 'repos/acme/widgets/pulls/99', '--include'],
|
||||
{
|
||||
cwd: '/repo-root'
|
||||
}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('returns no PR when linked PR REST fallback has an unclassified failure', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockRejectedValueOnce(new Error('GraphQL: server exploded'))
|
||||
.mockRejectedValueOnce(new Error('HTTP 500: server error'))
|
||||
.mockResolvedValueOnce({
|
||||
@@ -493,26 +485,26 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', 'repos/acme/widgets/pulls/99', '--include'],
|
||||
{
|
||||
cwd: '/repo-root'
|
||||
}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('does not continue to branch discovery when linked PR REST fallback is rate limited', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
|
||||
.mockRejectedValueOnce(new Error('REST API rate limit already exceeded'))
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
@@ -521,24 +513,24 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', 'repos/acme/widgets/pulls/99', '--include'],
|
||||
{
|
||||
cwd: '/repo-root'
|
||||
}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('uses linked PR number as the source of truth when provided', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 77,
|
||||
title: 'Linked PR lookup',
|
||||
@@ -557,8 +549,8 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledWith(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
@@ -566,7 +558,7 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -575,7 +567,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('normalizes exact linked PR fallback metadata when no GitHub remote is resolved', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce(null)
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 77,
|
||||
title: 'Linked fallback PR',
|
||||
@@ -596,13 +588,13 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/non-github-repo', 'feature/test', 77)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledWith(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
'77',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/non-github-repo' }
|
||||
)
|
||||
@@ -617,7 +609,7 @@ describe('getPRForBranch', () => {
|
||||
it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce(null)
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
metadataRequests.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 7,
|
||||
title: 'Fallback lookup',
|
||||
@@ -636,13 +628,13 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/non-github-repo', 'feature/test')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(metadataRequests).toHaveBeenCalledWith(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
'feature/test',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/non-github-repo' }
|
||||
)
|
||||
@@ -654,7 +646,7 @@ describe('getPRForBranch', () => {
|
||||
it('falls back to REST number lookup when linked PR GraphQL lookup is rate limited', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
@@ -673,7 +665,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
@@ -682,18 +674,18 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', 'repos/acme/widgets/pulls/99', '--include'],
|
||||
{
|
||||
cwd: '/repo-root'
|
||||
}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(2)
|
||||
expect(pr).toMatchObject({
|
||||
number: 99,
|
||||
state: 'merged',
|
||||
|
||||
@@ -475,9 +475,10 @@ describe('GitHub PR local runtime routing', () => {
|
||||
) {
|
||||
return { stdout: '[]' }
|
||||
}
|
||||
if (endpoint.endsWith('/commits/head-sha/check-runs?per_page=100')) {
|
||||
if (endpoint.endsWith('/commits/head-sha/check-runs?per_page=100&page=1')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
total_count: 1,
|
||||
check_runs: [
|
||||
{
|
||||
id: 88,
|
||||
@@ -490,11 +491,11 @@ describe('GitHub PR local runtime routing', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (endpoint.endsWith('/commits/head-sha/status?per_page=100')) {
|
||||
return { stdout: JSON.stringify({ statuses: [] }) }
|
||||
if (endpoint.endsWith('/commits/head-sha/status?per_page=100&page=1')) {
|
||||
return { stdout: JSON.stringify({ total_count: 0, statuses: [] }) }
|
||||
}
|
||||
if (endpoint.endsWith('/commits/head-sha/check-suites?per_page=100')) {
|
||||
return { stdout: JSON.stringify({ check_suites: [] }) }
|
||||
if (endpoint.endsWith('/commits/head-sha/check-suites?per_page=100&page=1')) {
|
||||
return { stdout: JSON.stringify({ total_count: 0, check_suites: [] }) }
|
||||
}
|
||||
if (endpoint.endsWith('/check-runs/88')) {
|
||||
return {
|
||||
|
||||
@@ -39,20 +39,28 @@ vi.mock('./github-api-repository', async (importOriginal) =>
|
||||
|
||||
import { getPRForBranch } from './client/lookup/get-pr-for-branch'
|
||||
import { mergePR } from './client/merge/merge-pr'
|
||||
import { resetGraphQLRateLimitGuardMocks } from './client-test-harness'
|
||||
import {
|
||||
installIndependentCheckFixtures,
|
||||
resetGraphQLRateLimitGuardMocks
|
||||
} from './client-test-harness'
|
||||
|
||||
const { ghExecFileAsyncMock, getOwnerRepoMock, acquireMock, releaseMock } = clientMocks
|
||||
const { getOwnerRepoMock, acquireMock, releaseMock } = clientMocks
|
||||
|
||||
const metadataRequests = vi.fn()
|
||||
const unexpectedChecks: string[][] = []
|
||||
afterEach(() => expect(unexpectedChecks).toEqual([]))
|
||||
|
||||
describe('GitHub GraphQL rate-limit guard', () => {
|
||||
beforeEach(() => {
|
||||
resetGraphQLRateLimitGuardMocks(clientMocks)
|
||||
installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks)
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' })
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
@@ -74,7 +82,20 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
headRefOid: 'a'.repeat(40),
|
||||
headRepositoryOwner: { login: 'stablyai' },
|
||||
headRepository: { name: 'orca' },
|
||||
commits: { nodes: [{ commit: { oid: 'a'.repeat(40), statusCheckRollup: null } }] },
|
||||
commits: {
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: 'a'.repeat(40),
|
||||
statusCheckRollup: null,
|
||||
checkSuites: {
|
||||
nodes: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
stack: { number: 51, size: 2, baseRefName: 'main' },
|
||||
stackEntry: { position: 2 }
|
||||
}
|
||||
@@ -160,10 +181,10 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
]
|
||||
})
|
||||
expect(pr?.mergeQueueRequired).toBe(true)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('number=202')
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain('branch=stack/models')
|
||||
const mergeQueueMetadataCall = ghExecFileAsyncMock.mock.calls.find(
|
||||
expect(metadataRequests).toHaveBeenCalledTimes(4)
|
||||
expect(metadataRequests.mock.calls[0][0]).toContain('number=202')
|
||||
expect(metadataRequests.mock.calls[1][0]).toContain('branch=stack/models')
|
||||
const mergeQueueMetadataCall = metadataRequests.mock.calls.find(
|
||||
([args]) => args.includes('graphql') && args.includes('branch=main')
|
||||
)
|
||||
expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main']))
|
||||
@@ -185,7 +206,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
baseSha: undefined
|
||||
}
|
||||
])('uses async merge only for GitHub-registered stacks using $objectFormat', async (scenario) => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 202,
|
||||
@@ -219,7 +240,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
const mergeCall = ghExecFileAsyncMock.mock.calls.find(([args]) =>
|
||||
const mergeCall = metadataRequests.mock.calls.find(([args]) =>
|
||||
args.includes('repos/stablyai/orca/pulls/202/merge-async')
|
||||
)
|
||||
expect(mergeCall?.[0]).toEqual(
|
||||
@@ -234,12 +255,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
expect(acquireMock).toHaveBeenCalledTimes(2)
|
||||
expect(releaseMock).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
metadataRequests.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never falls back to legacy merge after an async stack merge transport failure', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 202,
|
||||
@@ -267,7 +288,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: false, error: 'socket closed after request submission' })
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
metadataRequests.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
@@ -558,12 +579,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
])('fails closed on $failure', async (scenario) => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
if (scenario.probeResponse instanceof Error) {
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(scenario.probeResponse)
|
||||
metadataRequests.mockRejectedValueOnce(scenario.probeResponse)
|
||||
} else {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce(scenario.probeResponse)
|
||||
metadataRequests.mockResolvedValueOnce(scenario.probeResponse)
|
||||
}
|
||||
// Stub legacy merge fallthrough so disabling the guard cannot fail on an unrelated missing fixture.
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 202,
|
||||
@@ -589,20 +610,18 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
|
||||
expect.soft(result).toEqual({ ok: false, error: scenario.expectedError })
|
||||
expect
|
||||
.soft(ghExecFileAsyncMock.mock.calls.map(([args]) => args))
|
||||
.soft(metadataRequests.mock.calls.map(([args]) => args))
|
||||
.toEqual([['api', 'repos/stablyai/orca/pulls/202', '--include']])
|
||||
expect.soft(ghExecFileAsyncMock.mock.calls[0]?.[1]).toEqual(scenario.expectedOptions)
|
||||
expect.soft(metadataRequests.mock.calls[0]?.[1]).toEqual(scenario.expectedOptions)
|
||||
expect
|
||||
.soft(
|
||||
ghExecFileAsyncMock.mock.calls.some(([args]) =>
|
||||
metadataRequests.mock.calls.some(([args]) =>
|
||||
args.includes('repos/stablyai/orca/pulls/202/merge-async')
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
expect
|
||||
.soft(
|
||||
ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
|
||||
)
|
||||
.soft(metadataRequests.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge'))
|
||||
.toBe(false)
|
||||
expect.soft(acquireMock).toHaveBeenCalledTimes(1)
|
||||
expect.soft(releaseMock).toHaveBeenCalledTimes(1)
|
||||
@@ -619,7 +638,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
{ stackVariant: 'omits stack', stackField: {} },
|
||||
{ stackVariant: 'sets stack to null', stackField: { stack: null } }
|
||||
])('keeps legacy merge when an ordinary GitHub response $stackVariant', async (scenario) => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 13866,
|
||||
@@ -659,7 +678,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['pr', 'merge', '13866', '--squash', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) })
|
||||
@@ -669,7 +688,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
|
||||
it('keeps legacy merge for unregistered dependent PR chains', async () => {
|
||||
ghExecFileAsyncMock
|
||||
metadataRequests
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 202,
|
||||
@@ -704,7 +723,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
expect(metadataRequests).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { completePRCheckGraphQLResponse } from '../../shared/github/complete-pr-check-test-fixture'
|
||||
import type { GitHubClientMocks } from './client-test-mocks'
|
||||
import { _resetOwnerRepoCache } from './gh-utils'
|
||||
import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache'
|
||||
@@ -83,3 +85,151 @@ export function resetGraphQLRateLimitGuardMocks(mocks: GitHubClientMocks): void
|
||||
_resetPRStackSummaryCacheForTests()
|
||||
__resetPRConflictSummaryCachesForTests()
|
||||
}
|
||||
|
||||
export function mockPRLookupRoutes(
|
||||
mocks: GitHubClientMocks,
|
||||
routes: [string, () => Promise<{ stdout: string }>][],
|
||||
unavailableChecks = false,
|
||||
unexpectedRequests: string[][] = [],
|
||||
contexts: Record<string, unknown>[] = []
|
||||
): void {
|
||||
const { ghExecFileAsyncMock } = mocks
|
||||
const metadata = new Map<string, Record<string, unknown>>()
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
const repo = args[args.indexOf('--repo') + 1]
|
||||
const key =
|
||||
args[0] === 'pr'
|
||||
? args[1] === 'view'
|
||||
? `view:${repo}:${args[2]}`
|
||||
: `list:${repo}:${args[args.indexOf('--head') + 1]}`
|
||||
: args[1]
|
||||
const index = routes.findIndex(([route]) => route === key)
|
||||
if (index !== -1) {
|
||||
const [, respond] = routes.splice(index, 1)[0]!
|
||||
const result = await respond()
|
||||
if (args[0] === 'pr' && args[1] === 'view') {
|
||||
metadata.set(`${repo}:${args[2]}`, JSON.parse(result.stdout))
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (args[0] === 'api') {
|
||||
const target = [...metadata].find(
|
||||
([identity]) => args[1] === `repos/${identity.replace(':', '/pulls/')}`
|
||||
)
|
||||
if (target) {
|
||||
const [, item] = target
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: String(item.state).toLowerCase(),
|
||||
html_url: item.url,
|
||||
updated_at: item.updatedAt,
|
||||
draft: item.isDraft,
|
||||
head: { ref: item.headRefName, sha: item.headRefOid },
|
||||
base: { ref: item.baseRefName, sha: item.baseRefOid }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
args[1] === 'graphql' &&
|
||||
args.some((arg) => arg.startsWith('query=') && arg.includes('statusCheckRollup'))
|
||||
) {
|
||||
const field = (name: string) =>
|
||||
args.find((arg) => arg.startsWith(`${name}=`))?.slice(name.length + 1)
|
||||
const item = metadata.get(`${field('owner')}/${field('repo')}:${field('number')}`)
|
||||
if (item) {
|
||||
if (unavailableChecks) {
|
||||
throw new Error('checks unavailable')
|
||||
}
|
||||
return completePRCheckGraphQLResponse(
|
||||
Number(item.number),
|
||||
String(item.headRefOid),
|
||||
contexts
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
unavailableChecks &&
|
||||
args[0] === 'api' &&
|
||||
[...metadata].some(
|
||||
([target, item]) =>
|
||||
args[1] ===
|
||||
`repos/${target.split(':')[0]}/commits/${String(item.headRefOid)}/check-runs?per_page=100&page=1`
|
||||
)
|
||||
) {
|
||||
throw new Error('checks recovery unavailable')
|
||||
}
|
||||
unexpectedRequests.push(args)
|
||||
throw new Error('unexpected gh request')
|
||||
})
|
||||
}
|
||||
|
||||
/** Keep metadata/mutation scripts independent of head-pinned check collection. */
|
||||
export function installIndependentCheckFixtures(
|
||||
mocks: GitHubClientMocks,
|
||||
metadataRequests: Mock,
|
||||
unexpectedChecks: string[][]
|
||||
): void {
|
||||
const targets = new Map<
|
||||
string,
|
||||
{ number: number; head: string; contexts: Record<string, unknown>[] }
|
||||
>()
|
||||
metadataRequests.mockReset()
|
||||
unexpectedChecks.length = 0
|
||||
mocks.ghExecFileAsyncMock.mockImplementation(async (args: string[], ...options: unknown[]) => {
|
||||
const field = (name: string) =>
|
||||
args.find((arg) => arg.startsWith(`${name}=`))?.slice(name.length + 1)
|
||||
if (
|
||||
args[1] === 'graphql' &&
|
||||
args.some(
|
||||
(arg) =>
|
||||
arg.startsWith('query=') &&
|
||||
arg.includes('statusCheckRollup') &&
|
||||
!arg.includes('updatedAt')
|
||||
)
|
||||
) {
|
||||
const target = targets.get(`${field('owner')}/${field('repo')}:${field('number')}`)
|
||||
if (target) {
|
||||
return completePRCheckGraphQLResponse(target.number, target.head, target.contexts)
|
||||
}
|
||||
unexpectedChecks.push(args)
|
||||
throw new Error('checks requested for an unregistered metadata target')
|
||||
}
|
||||
const response = await metadataRequests(args, ...options)
|
||||
if (response?.stdout) {
|
||||
const exact = args[0] === 'pr' && args[1] === 'view'
|
||||
const restRepo =
|
||||
args[0] === 'api'
|
||||
? /^repos\/([^/]+\/[^/]+)\/pulls(?:\/\d+|\?head=)/.exec(args[1])?.[1]
|
||||
: undefined
|
||||
if (exact || restRepo) {
|
||||
const parsed = JSON.parse(response.stdout)
|
||||
for (const item of Array.isArray(parsed) ? parsed : [parsed]) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue
|
||||
}
|
||||
const head = exact ? item.headRefOid : item.head?.sha
|
||||
const repo =
|
||||
restRepo ??
|
||||
(args.includes('--repo')
|
||||
? args[args.indexOf('--repo') + 1]
|
||||
: /https?:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\//.exec(item.url)?.[1])
|
||||
if (repo && typeof item.number === 'number' && typeof head === 'string') {
|
||||
targets.set(`${repo}:${item.number}`, {
|
||||
number: item.number,
|
||||
head,
|
||||
contexts: (item.statusCheckRollup ?? []).map((row: Record<string, unknown>) => ({
|
||||
__typename: row.__typename ?? (row.context ? 'StatusContext' : 'CheckRun'),
|
||||
...(!row.context ? { checkSuite: { databaseId: 1 } } : {}),
|
||||
...row
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('getPRForBranch', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
|
||||
@@ -53,13 +53,55 @@ import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-
|
||||
import { _resetOwnerRepoCache } from './gh-utils'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
||||
|
||||
// Only fixture pr view; best-effort merge metadata failures must not hide its summary.
|
||||
// Metadata and complete checks are independent provider responses.
|
||||
function mockPullRequestDetail(payload: Record<string, unknown>): void {
|
||||
const head = 'a'.repeat(40)
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] !== 'pr' || args[1] !== 'view') {
|
||||
throw new Error('unexpected gh call')
|
||||
if (args[0] === 'pr' && args[1] === 'view') {
|
||||
return { stdout: JSON.stringify({ ...payload, headRefOid: head }) }
|
||||
}
|
||||
return { stdout: JSON.stringify(payload) }
|
||||
if (args.includes('graphql') && args.some((arg) => arg.includes('statusCheckRollup'))) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: payload.number,
|
||||
headRefOid: head,
|
||||
commits: {
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: head,
|
||||
statusCheckRollup: {
|
||||
contexts: {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these test payloads contain object rollup rows only.
|
||||
nodes: (payload.statusCheckRollup as Record<string, unknown>[]).map(
|
||||
(raw) => ({
|
||||
...raw,
|
||||
__typename:
|
||||
raw.__typename ?? (raw.state ? 'StatusContext' : 'CheckRun'),
|
||||
...(!raw.state ? { checkSuite: { databaseId: 7 } } : {})
|
||||
})
|
||||
),
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}
|
||||
},
|
||||
checkSuites: {
|
||||
nodes: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
throw new Error('unexpected gh call')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { GitHubApiRepository } from '../../github-api-repository'
|
||||
import type { GhExecOptions } from '../github-exec-scope'
|
||||
import { assertRateLimitBudget } from '../lookup/pr-lookup-rate-limit'
|
||||
import { readGraphQLPRCheckRollup } from './pr-check-rollup'
|
||||
import { readRestPRCheckRollup } from './rest-pr-check-pages'
|
||||
|
||||
export async function readCompletePRCheckObservation(
|
||||
repository: GitHubApiRepository,
|
||||
number: number,
|
||||
headSha: string | undefined,
|
||||
options: GhExecOptions
|
||||
): Promise<unknown[] | undefined> {
|
||||
if (!headSha) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
await assertRateLimitBudget('graphql', repository, options)
|
||||
return await readGraphQLPRCheckRollup(repository, number, headSha, options)
|
||||
} catch {
|
||||
try {
|
||||
return await readRestPRCheckRollup(repository, headSha, options)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizePRCheckHeadSha } from '../../../../shared/github/pr-check-head'
|
||||
import {
|
||||
withGitHubRepoExecution,
|
||||
githubRepoContext,
|
||||
@@ -14,21 +15,21 @@ import { mapCheckStatus, mapCheckConclusion } from '../../mappers'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
|
||||
import {
|
||||
PR_CHECKS_ROLLUP_QUERY,
|
||||
type GraphQLPRChecksResponse,
|
||||
type RestCheckRun,
|
||||
type RestCommitStatus,
|
||||
type RestCheckSuite
|
||||
} from './pr-checks-graphql-query'
|
||||
import { PR_CHECKS_ROLLUP_QUERY, type GraphQLPRChecksResponse } from './pr-checks-graphql-query'
|
||||
import {
|
||||
mapRestCheckRun,
|
||||
mapRestCommitStatus,
|
||||
mapGraphQLPRChecksResponse,
|
||||
getPendingApprovalCheckSuiteName,
|
||||
getPendingApprovalCheckSuiteUrl
|
||||
getPendingApprovalCheckSuiteUrl,
|
||||
isPendingApprovalSuiteWithoutRuns
|
||||
} from './pr-checks-response-mapping'
|
||||
import { parseActionsRunId } from './check-detail-field-mapping'
|
||||
import {
|
||||
readRestPRCheckRuns,
|
||||
readRestPRCommitStatuses,
|
||||
readRestPRCheckSuites
|
||||
} from './rest-pr-check-pages'
|
||||
export async function getPRChecksViaRestFallback(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
headSha: string | undefined,
|
||||
@@ -38,82 +39,63 @@ export async function getPRChecksViaRestFallback(
|
||||
if (!headSha) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await assertRateLimitBudget('core', ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const cacheArgs = noCache ? [] : ['--cache', '60s']
|
||||
const encodedHeadSha = encodeURIComponent(headSha)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-runs?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
const runs = await readRestPRCheckRuns(ownerRepo, headSha, ghOptions, Boolean(noCache))
|
||||
const checks = runs.map((run) =>
|
||||
mapRestCheckRun({
|
||||
...run,
|
||||
name: run.name ?? '',
|
||||
html_url: run.html_url ?? '',
|
||||
details_url: run.details_url ?? null
|
||||
})
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const checkRunData = JSON.parse(stdout) as {
|
||||
check_runs?: RestCheckRun[]
|
||||
}
|
||||
const checkRuns = (checkRunData.check_runs ?? []).map(mapRestCheckRun)
|
||||
const checkRunNames = new Set(checkRuns.map((check) => check.name))
|
||||
|
||||
let legacyStatuses: PRCheckDetail[] = []
|
||||
const checkRunNames = new Set(checks.map((check) => check.name))
|
||||
try {
|
||||
const statusResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
const statuses = await readRestPRCommitStatuses(
|
||||
ownerRepo,
|
||||
headSha,
|
||||
ghOptions,
|
||||
Boolean(noCache)
|
||||
)
|
||||
checks.push(
|
||||
...statuses
|
||||
.map(mapRestCommitStatus)
|
||||
.filter(
|
||||
(check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)
|
||||
)
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const statusData = JSON.parse(statusResult.stdout) as {
|
||||
statuses?: RestCommitStatus[]
|
||||
}
|
||||
legacyStatuses = (statusData.statuses ?? [])
|
||||
.map(mapRestCommitStatus)
|
||||
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
|
||||
} catch (err) {
|
||||
// Keep richer check-run rows when best-effort legacy-status enrichment fails.
|
||||
// Detail rows remain useful when best-effort enrichment fails.
|
||||
console.warn('getPRChecks REST status fallback failed:', err)
|
||||
}
|
||||
|
||||
let pendingApprovalChecks: PRCheckDetail[] = []
|
||||
try {
|
||||
const suitesResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-suites?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
const suites = await readRestPRCheckSuites(ownerRepo, headSha, ghOptions, Boolean(noCache))
|
||||
const represented = new Set(
|
||||
runs.flatMap((run) => (run.check_suite ? [run.check_suite.id] : []))
|
||||
)
|
||||
checks.push(
|
||||
...suites
|
||||
.filter((suite) =>
|
||||
isPendingApprovalSuiteWithoutRuns(
|
||||
suite.conclusion,
|
||||
suite.id !== undefined && represented.has(suite.id)
|
||||
)
|
||||
)
|
||||
.map((suite, index) => ({
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed' as const,
|
||||
conclusion: 'action_required' as const,
|
||||
url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
|
||||
}))
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const suitesData = JSON.parse(suitesResult.stdout) as {
|
||||
check_suites?: RestCheckSuite[]
|
||||
}
|
||||
pendingApprovalChecks = (suitesData.check_suites ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.map((suite, index) => ({
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed' as const,
|
||||
conclusion: 'action_required' as const,
|
||||
url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks REST check-suite fallback failed:', err)
|
||||
}
|
||||
|
||||
const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
|
||||
return checks.length > 0 ? checks : null
|
||||
const observedHeadSha = normalizePRCheckHeadSha(headSha)
|
||||
return checks.length > 0
|
||||
? checks.map((check) => (observedHeadSha ? { ...check, observedHeadSha } : check))
|
||||
: null
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err)
|
||||
return null
|
||||
@@ -132,7 +114,6 @@ async function readPRChecks(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PRCheckDetail[]> {
|
||||
void headSha
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { z } from 'zod'
|
||||
import { ghExecFileAsync } from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import type { GhExecOptions } from '../github-exec-scope'
|
||||
import { isPendingApprovalSuiteWithoutRuns } from './pr-checks-response-mapping'
|
||||
|
||||
type ConnectionKind = 'contexts' | 'suites'
|
||||
|
||||
export function prCheckRollupFields(kind?: ConnectionKind): string {
|
||||
return `commits(last: 1) { nodes { commit {
|
||||
oid
|
||||
${
|
||||
kind !== 'suites'
|
||||
? `statusCheckRollup { contexts(first: 100, after: $checksCursor) {
|
||||
nodes { __typename ... on CheckRun { status conclusion checkSuite { databaseId } }
|
||||
... on StatusContext { state } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
} }`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
kind !== 'contexts'
|
||||
? `checkSuites(first: 100, after: $suitesCursor) {
|
||||
nodes { databaseId conclusion }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
} } }`
|
||||
}
|
||||
|
||||
const checkConclusion = z.enum([
|
||||
'ACTION_REQUIRED',
|
||||
'CANCELLED',
|
||||
'FAILURE',
|
||||
'NEUTRAL',
|
||||
'SKIPPED',
|
||||
'STALE',
|
||||
'STARTUP_FAILURE',
|
||||
'SUCCESS',
|
||||
'TIMED_OUT'
|
||||
])
|
||||
const contextSchema = z.discriminatedUnion('__typename', [
|
||||
z.object({
|
||||
__typename: z.literal('CheckRun'),
|
||||
status: z.enum(['COMPLETED', 'IN_PROGRESS', 'QUEUED', 'REQUESTED', 'WAITING', 'PENDING']),
|
||||
conclusion: checkConclusion.nullable(),
|
||||
checkSuite: z.object({ databaseId: z.number() })
|
||||
}),
|
||||
z.object({
|
||||
__typename: z.literal('StatusContext'),
|
||||
state: z.enum(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS'])
|
||||
})
|
||||
])
|
||||
const connectionSchema = z.object({
|
||||
nodes: z.array(contextSchema),
|
||||
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() })
|
||||
})
|
||||
|
||||
export const prCheckRollupCommitsSchema = z.object({
|
||||
nodes: z
|
||||
.array(
|
||||
z.object({
|
||||
commit: z.object({
|
||||
oid: z.string().min(1),
|
||||
statusCheckRollup: z.object({ contexts: connectionSchema }).nullable().optional(),
|
||||
checkSuites: z
|
||||
.object({
|
||||
nodes: z.array(
|
||||
z.object({ databaseId: z.number(), conclusion: checkConclusion.nullable() })
|
||||
),
|
||||
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() })
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
})
|
||||
)
|
||||
.max(1)
|
||||
})
|
||||
|
||||
type CheckCommits = z.infer<typeof prCheckRollupCommitsSchema>
|
||||
type CheckPage = { number: number; headRefOid: string; commits: CheckCommits }
|
||||
const pageSchema = z.object({
|
||||
data: z.object({
|
||||
repository: z.object({
|
||||
pullRequest: z.object({
|
||||
number: z.number(),
|
||||
headRefOid: z.string(),
|
||||
commits: prCheckRollupCommitsSchema
|
||||
})
|
||||
})
|
||||
}),
|
||||
errors: z.array(z.unknown()).optional()
|
||||
})
|
||||
|
||||
export async function collectPRCheckRollup(
|
||||
first: CheckPage,
|
||||
readPage: (kind: ConnectionKind, cursor: string) => Promise<CheckPage>
|
||||
): Promise<unknown[]> {
|
||||
const initial = first.commits.nodes[0]?.commit
|
||||
if (
|
||||
!initial ||
|
||||
initial.oid !== first.headRefOid ||
|
||||
!initial.checkSuites ||
|
||||
initial.statusCheckRollup === undefined
|
||||
) {
|
||||
throw new Error('Incomplete PR checks observation')
|
||||
}
|
||||
const collect = async (kind: ConnectionKind) => {
|
||||
const connection = (commit: typeof initial) =>
|
||||
kind === 'contexts' ? commit.statusCheckRollup?.contexts : commit.checkSuites
|
||||
let page = connection(initial)
|
||||
const nodes: Record<string, unknown>[] = [...(page?.nodes ?? [])]
|
||||
const cursors = new Set<string>()
|
||||
while (page?.pageInfo.hasNextPage) {
|
||||
const cursor = page.pageInfo.endCursor
|
||||
if (!cursor || cursors.has(cursor)) {
|
||||
throw new Error('Incomplete PR checks continuation')
|
||||
}
|
||||
cursors.add(cursor)
|
||||
const next = await readPage(kind, cursor)
|
||||
const commit = next.commits.nodes[0]?.commit
|
||||
if (
|
||||
next.number !== first.number ||
|
||||
next.headRefOid !== first.headRefOid ||
|
||||
!commit ||
|
||||
commit.oid !== initial.oid
|
||||
) {
|
||||
throw new Error('PR head changed during checks observation')
|
||||
}
|
||||
page = connection(commit)
|
||||
if (!page) {
|
||||
throw new Error('Missing PR checks continuation')
|
||||
}
|
||||
nodes.push(...page.nodes)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
const contexts = await collect('contexts')
|
||||
const suites = await collect('suites')
|
||||
const representedSuites = new Set(
|
||||
contexts.flatMap((context) => {
|
||||
const suite = context.checkSuite
|
||||
return context.__typename === 'CheckRun' &&
|
||||
typeof suite === 'object' &&
|
||||
suite !== null &&
|
||||
'databaseId' in suite &&
|
||||
typeof suite.databaseId === 'number'
|
||||
? [suite.databaseId]
|
||||
: []
|
||||
})
|
||||
)
|
||||
return [
|
||||
...contexts,
|
||||
...suites
|
||||
.filter((suite) =>
|
||||
isPendingApprovalSuiteWithoutRuns(
|
||||
suite.conclusion,
|
||||
typeof suite.databaseId === 'number' && representedSuites.has(suite.databaseId)
|
||||
)
|
||||
)
|
||||
.map(() => ({ status: 'COMPLETED', conclusion: 'ACTION_REQUIRED' }))
|
||||
]
|
||||
}
|
||||
|
||||
export function createPRCheckRollupPageReader(
|
||||
repository: GitHubApiRepository,
|
||||
number: number,
|
||||
options: GhExecOptions
|
||||
): (kind?: ConnectionKind, cursor?: string) => Promise<CheckPage> {
|
||||
return async (kind, cursor) => {
|
||||
const cursors = [
|
||||
...(kind !== 'suites' ? ['$checksCursor: String'] : []),
|
||||
...(kind !== 'contexts' ? ['$suitesCursor: String'] : [])
|
||||
].join(', ')
|
||||
const query = `query($owner: String!, $repo: String!, $number: Int!,
|
||||
${cursors}) {
|
||||
repository(owner: $owner, name: $repo) { pullRequest(number: $number) {
|
||||
number headRefOid ${prCheckRollupFields(kind)}
|
||||
} }
|
||||
}`
|
||||
const args = [
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`owner=${repository.owner}`,
|
||||
'-f',
|
||||
`repo=${repository.repo}`,
|
||||
'-F',
|
||||
`number=${number}`
|
||||
]
|
||||
if (cursor) {
|
||||
args.push('-f', `${kind === 'suites' ? 'suitesCursor' : 'checksCursor'}=${cursor}`)
|
||||
}
|
||||
noteRepositoryRateLimitSpend(repository, 'graphql', 1, options)
|
||||
const { stdout } = await ghExecFileAsync(args, {
|
||||
...options,
|
||||
...githubHostExecOptions(repository)
|
||||
})
|
||||
const page = pageSchema.parse(JSON.parse(stdout))
|
||||
if (page.errors?.length) {
|
||||
throw new Error('Incomplete PR checks response')
|
||||
}
|
||||
return page.data.repository.pullRequest
|
||||
}
|
||||
}
|
||||
|
||||
export async function readGraphQLPRCheckRollup(
|
||||
repository: GitHubApiRepository,
|
||||
number: number,
|
||||
headSha: string,
|
||||
options: GhExecOptions
|
||||
): Promise<unknown[]> {
|
||||
const readPage = createPRCheckRollupPageReader(repository, number, options)
|
||||
const first = await readPage()
|
||||
if (first.number !== number || first.headRefOid !== headSha) {
|
||||
throw new Error('PR head changed before checks observation')
|
||||
}
|
||||
return collectPRCheckRollup(first, readPage)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
statusCheckRollup {
|
||||
contexts(first: 100) {
|
||||
nodes {
|
||||
@@ -53,6 +54,8 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
`
|
||||
|
||||
export type GraphQLPRChecksResponse = {
|
||||
errors?: unknown[]
|
||||
|
||||
data?: {
|
||||
repository?: {
|
||||
pullRequest?: {
|
||||
@@ -66,6 +69,8 @@ export type GraphQLPRChecksResponse = {
|
||||
}
|
||||
|
||||
export type GraphQLPRChecksCommit = {
|
||||
oid?: string | null
|
||||
|
||||
statusCheckRollup?: {
|
||||
contexts?: {
|
||||
nodes?: GraphQLStatusCheckContext[] | null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizePRCheckHeadSha } from '../../../../shared/github/pr-check-head'
|
||||
import type { PRCheckDetail } from '../../../../shared/github/check-types'
|
||||
import { githubRepositoryWebHost, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import {
|
||||
@@ -107,6 +108,12 @@ export function mapGraphQLPendingApprovalCheckSuite(
|
||||
}
|
||||
}
|
||||
|
||||
export function isPendingApprovalSuiteWithoutRuns(conclusion: unknown, hasRuns: boolean): boolean {
|
||||
return (
|
||||
typeof conclusion === 'string' && conclusion.toLowerCase() === 'action_required' && !hasRuns
|
||||
)
|
||||
}
|
||||
|
||||
export function mapGraphQLPRChecksResponse(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
response: GraphQLPRChecksResponse
|
||||
@@ -137,16 +144,28 @@ export function mapGraphQLPRChecksResponse(
|
||||
.map(mapGraphQLStatusContext)
|
||||
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
|
||||
const pendingApprovalChecks = (commit.checkSuites?.nodes ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.filter((suite) => {
|
||||
const suiteId = nullableNumber(suite.databaseId)
|
||||
return suiteId === null || !checkSuiteIdsWithRuns.has(suiteId)
|
||||
return isPendingApprovalSuiteWithoutRuns(
|
||||
suite.conclusion,
|
||||
suiteId !== null && checkSuiteIdsWithRuns.has(suiteId)
|
||||
)
|
||||
})
|
||||
.map((suite, index) =>
|
||||
mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index)
|
||||
)
|
||||
|
||||
return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
|
||||
const observedHeadSha = normalizePRCheckHeadSha(commit.oid)
|
||||
const provedHead =
|
||||
observedHeadSha &&
|
||||
observedHeadSha === normalizePRCheckHeadSha(pullRequest.headRefOid) &&
|
||||
(response.errors === undefined ||
|
||||
(Array.isArray(response.errors) && response.errors.length === 0))
|
||||
? observedHeadSha
|
||||
: undefined
|
||||
return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks].map((check) =>
|
||||
provedHead ? { ...check, observedHeadSha: provedHead } : check
|
||||
)
|
||||
}
|
||||
|
||||
export function getPendingApprovalCheckSuiteName(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { z } from 'zod'
|
||||
import { ghExecFileAsync } from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import type { GhExecOptions } from '../github-exec-scope'
|
||||
import { assertRateLimitBudget } from '../lookup/pr-lookup-rate-limit'
|
||||
import { isPendingApprovalSuiteWithoutRuns } from './pr-checks-response-mapping'
|
||||
|
||||
const checkRunSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().optional(),
|
||||
status: z.string(),
|
||||
conclusion: z.string().nullable(),
|
||||
html_url: z.string().nullish(),
|
||||
details_url: z.string().nullish(),
|
||||
check_suite: z.object({ id: z.number() }).nullish()
|
||||
})
|
||||
const commitStatusSchema = z.object({
|
||||
context: z.string().optional(),
|
||||
state: z.string(),
|
||||
target_url: z.string().nullish()
|
||||
})
|
||||
const checkSuiteSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
status: z.string().nullable(),
|
||||
conclusion: z.string().nullable(),
|
||||
app: z.object({ name: z.string().nullish(), slug: z.string().nullish() }).nullish()
|
||||
})
|
||||
|
||||
async function readPages<T>(
|
||||
repository: GitHubApiRepository,
|
||||
headSha: string,
|
||||
options: GhExecOptions,
|
||||
endpoint: string,
|
||||
field: string,
|
||||
row: z.ZodType<T>,
|
||||
noCache: boolean
|
||||
): Promise<T[]> {
|
||||
const schema = z.looseObject({
|
||||
total_count: z.number().int().nonnegative()
|
||||
})
|
||||
const rows: T[] = []
|
||||
let total: number | undefined
|
||||
for (let page = 1; ; page++) {
|
||||
await assertRateLimitBudget('core', repository, options)
|
||||
const path = `repos/${repository.owner}/${repository.repo}/commits/${encodeURIComponent(headSha)}/${endpoint}?per_page=100&page=${page}`
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', ...(noCache ? [] : ['--cache', '60s']), path],
|
||||
{ ...options, ...githubHostExecOptions(repository) }
|
||||
)
|
||||
noteRepositoryRateLimitSpend(repository, 'core', 1, options)
|
||||
const result = schema.parse(JSON.parse(stdout))
|
||||
const count = result.total_count
|
||||
const pageRows = z.array(row).parse(result[field])
|
||||
if (total !== undefined && total !== count) {
|
||||
throw new Error('PR checks changed during REST pagination')
|
||||
}
|
||||
total = count
|
||||
rows.push(...pageRows)
|
||||
if (rows.length > total || (pageRows.length === 0 && rows.length < total)) {
|
||||
throw new Error('Incomplete REST PR checks response')
|
||||
}
|
||||
if (rows.length === total) {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readRestPRCheckRuns(
|
||||
repository: GitHubApiRepository,
|
||||
headSha: string,
|
||||
options: GhExecOptions,
|
||||
noCache = true
|
||||
) {
|
||||
return readPages(
|
||||
repository,
|
||||
headSha,
|
||||
options,
|
||||
'check-runs',
|
||||
'check_runs',
|
||||
checkRunSchema,
|
||||
noCache
|
||||
)
|
||||
}
|
||||
|
||||
export function readRestPRCommitStatuses(
|
||||
repository: GitHubApiRepository,
|
||||
headSha: string,
|
||||
options: GhExecOptions,
|
||||
noCache = true
|
||||
) {
|
||||
return readPages(repository, headSha, options, 'status', 'statuses', commitStatusSchema, noCache)
|
||||
}
|
||||
|
||||
export function readRestPRCheckSuites(
|
||||
repository: GitHubApiRepository,
|
||||
headSha: string,
|
||||
options: GhExecOptions,
|
||||
noCache = true
|
||||
) {
|
||||
return readPages(
|
||||
repository,
|
||||
headSha,
|
||||
options,
|
||||
'check-suites',
|
||||
'check_suites',
|
||||
checkSuiteSchema,
|
||||
noCache
|
||||
)
|
||||
}
|
||||
|
||||
export async function readRestPRCheckRollup(
|
||||
repository: GitHubApiRepository,
|
||||
headSha: string,
|
||||
options: GhExecOptions
|
||||
): Promise<unknown[]> {
|
||||
const runs = await readRestPRCheckRuns(repository, headSha, options)
|
||||
const statuses = await readRestPRCommitStatuses(repository, headSha, options)
|
||||
const suites = await readRestPRCheckSuites(repository, headSha, options)
|
||||
const representedSuites = new Set(
|
||||
runs.flatMap((run) => (run.check_suite ? [run.check_suite.id] : []))
|
||||
)
|
||||
return [
|
||||
...runs.map((run) => ({ status: run.status, conclusion: run.conclusion })),
|
||||
...statuses,
|
||||
...suites
|
||||
.filter((suite) =>
|
||||
isPendingApprovalSuiteWithoutRuns(
|
||||
suite.conclusion,
|
||||
suite.id !== undefined && representedSuites.has(suite.id)
|
||||
)
|
||||
)
|
||||
.map(() => ({ status: 'COMPLETED', conclusion: 'ACTION_REQUIRED' }))
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,26 @@ import {
|
||||
type MainWorkItem
|
||||
} from './../map/work-item-field-coercion'
|
||||
import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item'
|
||||
import { readCompletePRCheckObservation } from '../check/complete-pr-check-observation'
|
||||
import { summarizePRCheckRollup } from '../../../../shared/source-control/checks/pr-check-status'
|
||||
import { ownerRepoFromPullRequestUrl } from '../github-exec-scope'
|
||||
|
||||
async function withCompletePRChecks(
|
||||
item: MainWorkItem,
|
||||
repository: GitHubApiRepository | null,
|
||||
number: number,
|
||||
options: GhExecOptions
|
||||
): Promise<MainWorkItem> {
|
||||
const source = repository ?? ownerRepoFromPullRequestUrl(item.url)
|
||||
const rollup =
|
||||
source && item.number === number
|
||||
? await readCompletePRCheckObservation(source, number, item.headSha, options)
|
||||
: undefined
|
||||
return {
|
||||
...item,
|
||||
checksSummary: rollup === undefined ? undefined : summarizePRCheckRollup(rollup)
|
||||
}
|
||||
}
|
||||
export async function fetchIssueWorkItem(
|
||||
repoPath: string,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
@@ -142,7 +162,12 @@ export async function fetchPullRequestWorkItem(
|
||||
ghOptions
|
||||
)
|
||||
const item = JSON.parse(stdout) as Record<string, unknown>
|
||||
const mapped = mapPullRequestWorkItem(item, ownerRepo)
|
||||
const mapped = await withCompletePRChecks(
|
||||
mapPullRequestWorkItem(item, ownerRepo),
|
||||
ownerRepo,
|
||||
number,
|
||||
ghOptions
|
||||
)
|
||||
// Best-effort merge metadata must not trigger a REST fallback that drops latestReviews.
|
||||
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
|
||||
try {
|
||||
@@ -170,9 +195,11 @@ export async function fetchPullRequestWorkItem(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
|
||||
ghOptions
|
||||
)
|
||||
const mapped = mapPullRequestWorkItem(
|
||||
JSON.parse(stdout) as Record<string, unknown>,
|
||||
ownerRepo
|
||||
const mapped = await withCompletePRChecks(
|
||||
mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>, ownerRepo),
|
||||
ownerRepo,
|
||||
number,
|
||||
ghOptions
|
||||
)
|
||||
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
|
||||
return { ...mapped, ...reviewFields }
|
||||
@@ -188,7 +215,12 @@ export async function fetchPullRequestWorkItem(
|
||||
['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS],
|
||||
ghOptions
|
||||
)
|
||||
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
|
||||
return withCompletePRChecks(
|
||||
mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>),
|
||||
null,
|
||||
number,
|
||||
ghOptions
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchPullRequestWorkItemFromCandidates(
|
||||
|
||||
@@ -9,17 +9,19 @@ import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { isNoPullRequestError } from './../gh-error-predicates'
|
||||
import {
|
||||
PR_LOOKUP_JSON_FIELDS,
|
||||
normalizePullRequestLookupData,
|
||||
PR_BRANCH_LIST_JSON_FIELDS,
|
||||
mapRestPullRequest,
|
||||
normalizePullRequestLookupData,
|
||||
pullRequestMatchesHeadRepository,
|
||||
type PullRequestLookupData,
|
||||
type PullRequestLookupPolicy,
|
||||
type RestPullRequest
|
||||
} from './pull-request-lookup-data'
|
||||
import { getPRByNumber } from './pr-number-lookup'
|
||||
import { hydratePullRequestLookupData } from './pull-request-lookup-hydration'
|
||||
import { revalidateGitHubRestJson } from '../../github-rest-revalidation'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import { ownerRepoFromPullRequestUrl } from '../github-exec-scope'
|
||||
|
||||
// Why: GitHub lists a branch's PRs newest first, so with a page of one a closed PR opened after
|
||||
// the live one wins. Fetch a page and prefer the open PR; fall back to the newest one.
|
||||
@@ -72,7 +74,7 @@ export async function getFallbackPRListForBranch(
|
||||
const list = JSON.parse(stdout) as PullRequestLookupData[]
|
||||
const pr = preferOpenPullRequest(list)
|
||||
// Branch lists paginate PRs, not their nested check contexts.
|
||||
return pr ? { ...pr, checksUnavailable: true } : null
|
||||
return pr ? { ...pr, statusCheckRollup: [], checksUnavailable: true } : null
|
||||
}
|
||||
|
||||
export async function hydrateBranchLookupWithExactPR(
|
||||
@@ -189,13 +191,20 @@ export async function lookupPRByBranchName(args: {
|
||||
args.ghOptions
|
||||
)
|
||||
const parsedData: PullRequestLookupData = JSON.parse(stdout)
|
||||
const lookup = {
|
||||
data: normalizePullRequestLookupData({
|
||||
...parsedData,
|
||||
checksUnavailable: false
|
||||
}),
|
||||
dataRepo: null
|
||||
}
|
||||
const repository = ownerRepoFromPullRequestUrl(parsedData.url)
|
||||
const data = repository
|
||||
? await hydratePullRequestLookupData(
|
||||
repository,
|
||||
{ ...parsedData, checksUnavailable: true },
|
||||
args.ghOptions,
|
||||
args.executionScope
|
||||
)
|
||||
: {
|
||||
...normalizePullRequestLookupData(parsedData),
|
||||
statusCheckRollup: [],
|
||||
checksUnavailable: true
|
||||
}
|
||||
const lookup = { data, dataRepo: null }
|
||||
return !args.policy || (await args.policy.accepts(lookup.data, null))
|
||||
? lookup
|
||||
: { data: null, dataRepo: null }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { classifyPRRefreshError } from '../../pr-refresh-error-classification'
|
||||
import { ghExecFileAsync } from '../../gh-utils'
|
||||
import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
@@ -8,8 +9,8 @@ import {
|
||||
} from './../gh-error-predicates'
|
||||
import {
|
||||
PR_LOOKUP_JSON_FIELDS,
|
||||
mapRestPullRequest,
|
||||
normalizePullRequestLookupData,
|
||||
mapRestPullRequest,
|
||||
type PullRequestLookupData,
|
||||
type PullRequestLookupPolicy,
|
||||
type RestPullRequest
|
||||
@@ -18,6 +19,7 @@ import { hydratePullRequestLookupData } from './pull-request-lookup-hydration'
|
||||
import { isGitObjectId, isUsableRestStackMetadata } from './rest-stack-metadata-validation'
|
||||
import { revalidateGitHubRestJson } from '../../github-rest-revalidation'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import { ownerRepoFromPullRequestUrl, sameOwnerRepo } from '../github-exec-scope'
|
||||
import { readPullRequestSidebarQuery } from './pull-request-sidebar-query'
|
||||
export async function getRestPRByNumber(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
@@ -61,22 +63,59 @@ export async function getPRByNumber(
|
||||
options: { includeStackSummary?: boolean } = {}
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
try {
|
||||
const sidebarData =
|
||||
options.includeStackSummary && knownPullRequestData === undefined
|
||||
? await readPullRequestSidebarQuery(ownerRepo, number, ghOptions)
|
||||
: undefined
|
||||
let sidebarData: Awaited<ReturnType<typeof readPullRequestSidebarQuery>>
|
||||
try {
|
||||
sidebarData =
|
||||
options.includeStackSummary || knownPullRequestData !== undefined
|
||||
? await readPullRequestSidebarQuery(ownerRepo, number, ghOptions)
|
||||
: undefined
|
||||
} catch (error) {
|
||||
if (
|
||||
!knownPullRequestData ||
|
||||
['repo_unavailable', 'permission', 'rate_limited', 'auth'].includes(
|
||||
classifyPRRefreshError(error)
|
||||
)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
// Optional combined reads retain the exact metadata recovery path.
|
||||
sidebarData = undefined
|
||||
}
|
||||
if (sidebarData === null) {
|
||||
return null
|
||||
}
|
||||
if (sidebarData) {
|
||||
const { stack, ...prData } = sidebarData
|
||||
const known = knownPullRequestData
|
||||
const sameIdentity =
|
||||
known &&
|
||||
known.number === number &&
|
||||
sameOwnerRepo(ownerRepoFromPullRequestUrl(known.url), ownerRepo) &&
|
||||
known.headRepositoryOwner?.login?.toLowerCase() ===
|
||||
prData.headRepositoryOwner?.login?.toLowerCase() &&
|
||||
known.headRepository?.name?.toLowerCase() === prData.headRepository?.name?.toLowerCase()
|
||||
const compatibleStack =
|
||||
sameIdentity &&
|
||||
known.headRefOid === prData.headRefOid &&
|
||||
known.baseRefOid === prData.baseRefOid &&
|
||||
stack &&
|
||||
known.stack?.number === stack.number &&
|
||||
known.stack.position === stack.position &&
|
||||
known.stack.baseRefName === stack.baseRefName
|
||||
? { ...known.stack, ...stack }
|
||||
: stack
|
||||
const hydrated = await hydratePullRequestLookupData(
|
||||
ownerRepo,
|
||||
prData,
|
||||
{
|
||||
...prData,
|
||||
...(sameIdentity && known.headDefaultBranchName
|
||||
? { headDefaultBranchName: known.headDefaultBranchName }
|
||||
: {})
|
||||
},
|
||||
ghOptions,
|
||||
executionScope
|
||||
)
|
||||
return { ...hydrated, ...(stack ? { stack } : {}) }
|
||||
return { ...hydrated, ...(compatibleStack ? { stack: compatibleStack } : {}) }
|
||||
}
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
@@ -97,7 +136,7 @@ export async function getPRByNumber(
|
||||
{
|
||||
...knownPullRequestData,
|
||||
...exactData,
|
||||
checksUnavailable: false,
|
||||
checksUnavailable: true,
|
||||
...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {})
|
||||
},
|
||||
ghOptions,
|
||||
@@ -114,7 +153,12 @@ export async function getPRByNumber(
|
||||
? await getRestPRByNumber(ownerRepo, number, ghOptions)
|
||||
: knownPullRequestData
|
||||
return restData
|
||||
? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope)
|
||||
? hydratePullRequestLookupData(
|
||||
ownerRepo,
|
||||
{ ...restData, checksUnavailable: true },
|
||||
ghOptions,
|
||||
executionScope
|
||||
)
|
||||
: null
|
||||
} catch (restErr) {
|
||||
if (isNotFoundGhError(restErr)) {
|
||||
@@ -171,13 +215,20 @@ export async function lookupPRByNumber(args: {
|
||||
args.ghOptions
|
||||
)
|
||||
const parsedData: PullRequestLookupData = JSON.parse(stdout)
|
||||
const lookup = {
|
||||
data: normalizePullRequestLookupData({
|
||||
...parsedData,
|
||||
checksUnavailable: false
|
||||
}),
|
||||
dataRepo: null
|
||||
}
|
||||
const repository = ownerRepoFromPullRequestUrl(parsedData.url)
|
||||
const data = repository
|
||||
? await hydratePullRequestLookupData(
|
||||
repository,
|
||||
{ ...parsedData, checksUnavailable: true },
|
||||
args.ghOptions,
|
||||
args.executionScope
|
||||
)
|
||||
: {
|
||||
...normalizePullRequestLookupData(parsedData),
|
||||
statusCheckRollup: [],
|
||||
checksUnavailable: true
|
||||
}
|
||||
const lookup = { data, dataRepo: null }
|
||||
return !args.policy || (await args.policy.accepts(lookup.data, null))
|
||||
? lookup
|
||||
: { data: null, dataRepo: null }
|
||||
|
||||
@@ -40,7 +40,10 @@ export function assemblePRRefreshFoundOutcome(args: {
|
||||
title: data.title,
|
||||
state: mapPRState(data.state, data.isDraft),
|
||||
url: data.url,
|
||||
checksStatus: checksSummary.state === 'none' ? 'neutral' : checksSummary.state,
|
||||
checksStatus:
|
||||
data.checksUnavailable !== false || checksSummary.state === 'none'
|
||||
? 'neutral'
|
||||
: checksSummary.state,
|
||||
...(data.checksUnavailable === false ? { checksSummary } : {}),
|
||||
updatedAt: data.updatedAt,
|
||||
mergeable,
|
||||
|
||||
@@ -81,10 +81,10 @@ export type RestPullRequest = {
|
||||
}
|
||||
|
||||
export const PR_LOOKUP_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
|
||||
export const PR_BRANCH_LIST_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
'number,title,state,url,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository'
|
||||
|
||||
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
|
||||
userRefresh?: boolean
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
normalizePullRequestLookupData,
|
||||
type PullRequestLookupData
|
||||
} from './pull-request-lookup-data'
|
||||
import { readCompletePRCheckObservation } from '../check/complete-pr-check-observation'
|
||||
export async function hydratePullRequestLookupData(
|
||||
ownerRepo: OwnerRepo,
|
||||
data: PullRequestLookupData,
|
||||
@@ -13,6 +14,10 @@ export async function hydratePullRequestLookupData(
|
||||
executionScope: string
|
||||
): Promise<PullRequestLookupData> {
|
||||
const normalized = normalizePullRequestLookupData(data)
|
||||
const checks =
|
||||
data.checksUnavailable === false
|
||||
? data.statusCheckRollup
|
||||
: await readCompletePRCheckObservation(ownerRepo, data.number, data.headRefOid, ghOptions)
|
||||
const hasRichMergeFields =
|
||||
'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
|
||||
const mergeMetadata =
|
||||
@@ -26,6 +31,8 @@ export async function hydratePullRequestLookupData(
|
||||
: undefined
|
||||
return {
|
||||
...normalized,
|
||||
statusCheckRollup: checks ?? [],
|
||||
checksUnavailable: checks === undefined,
|
||||
...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}),
|
||||
...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}),
|
||||
...(mergeMetadata?.mergeMethodSettings
|
||||
|
||||
@@ -5,26 +5,16 @@ import { githubHostExecOptions, type GitHubApiRepository } from '../../github-ap
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import type { GhExecOptions } from '../github-exec-scope'
|
||||
import type { PullRequestLookupData } from './pull-request-lookup-data'
|
||||
import {
|
||||
collectPRCheckRollup,
|
||||
createPRCheckRollupPageReader,
|
||||
prCheckRollupCommitsSchema,
|
||||
prCheckRollupFields
|
||||
} from '../check/pr-check-rollup'
|
||||
|
||||
const CHECKS_FIELDS = `commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
statusCheckRollup {
|
||||
contexts(first: 100, after: $checksCursor) {
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun { status conclusion }
|
||||
... on StatusContext { state }
|
||||
}
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
const CHECKS_FIELDS = prCheckRollupFields()
|
||||
|
||||
const SIDEBAR_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $checksCursor: String) {
|
||||
const SIDEBAR_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $checksCursor: String, $suitesCursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
number title state url updatedAt isDraft mergeable reviewDecision mergeStateStatus
|
||||
@@ -39,31 +29,6 @@ const SIDEBAR_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $ch
|
||||
}
|
||||
}`
|
||||
|
||||
const CHECKS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $checksCursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) { number ${CHECKS_FIELDS} }
|
||||
}
|
||||
}`
|
||||
|
||||
const commitsSchema = z.object({
|
||||
nodes: z
|
||||
.array(
|
||||
z.object({
|
||||
commit: z.object({
|
||||
oid: z.string(),
|
||||
statusCheckRollup: z
|
||||
.object({
|
||||
contexts: z.object({
|
||||
nodes: z.array(z.record(z.string(), z.unknown()).nullable()),
|
||||
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() })
|
||||
})
|
||||
})
|
||||
.nullable()
|
||||
})
|
||||
})
|
||||
)
|
||||
.max(1)
|
||||
})
|
||||
const errorsSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -91,7 +56,7 @@ const pullRequestSchema = z.object({
|
||||
isCrossRepository: z.boolean().optional(),
|
||||
headRepositoryOwner: z.object({ login: z.string() }).nullable(),
|
||||
headRepository: z.object({ name: z.string() }).nullable(),
|
||||
commits: commitsSchema,
|
||||
commits: z.unknown(),
|
||||
stack: z.unknown().optional(),
|
||||
stackEntry: z.unknown().optional()
|
||||
})
|
||||
@@ -109,14 +74,6 @@ const missingEnvelopeSchema = z.object({
|
||||
data: z.object({ repository: z.object({ pullRequest: z.null() }) }),
|
||||
errors: errorsSchema
|
||||
})
|
||||
const checksEnvelopeSchema = z.object({
|
||||
data: z.object({
|
||||
repository: z.object({
|
||||
pullRequest: z.object({ number: z.number(), commits: commitsSchema })
|
||||
})
|
||||
}),
|
||||
errors: errorsSchema
|
||||
})
|
||||
|
||||
async function readQuery(
|
||||
repository: GitHubApiRepository,
|
||||
@@ -188,13 +145,16 @@ export async function readPullRequestSidebarQuery(
|
||||
return undefined
|
||||
}
|
||||
const envelope = parsed.data
|
||||
const stackErrors = envelope.errors ?? []
|
||||
const errors = envelope.errors ?? []
|
||||
const stackErrors = errors.filter(
|
||||
({ path }) => path?.[2] === 'stack' || path?.[2] === 'stackEntry'
|
||||
)
|
||||
if (
|
||||
stackErrors.some(
|
||||
errors.some(
|
||||
({ path }) =>
|
||||
path?.[0] !== 'repository' ||
|
||||
path[1] !== 'pullRequest' ||
|
||||
(path[2] !== 'stack' && path[2] !== 'stackEntry')
|
||||
(path[2] !== 'stack' && path[2] !== 'stackEntry' && path[2] !== 'commits')
|
||||
)
|
||||
) {
|
||||
if (response.failure) {
|
||||
@@ -206,37 +166,21 @@ export async function readPullRequestSidebarQuery(
|
||||
if (pr.number !== number) {
|
||||
return undefined
|
||||
}
|
||||
let contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts
|
||||
const statusCheckRollup = contexts?.nodes.flatMap((node) => (node ? [node] : [])) ?? []
|
||||
const cursors = new Set<string>()
|
||||
while (contexts?.pageInfo.hasNextPage) {
|
||||
const cursor = contexts.pageInfo.endCursor
|
||||
if (!cursor || cursors.has(cursor)) {
|
||||
return undefined
|
||||
let statusCheckRollup: unknown[] | undefined
|
||||
try {
|
||||
if (!pr.headRefOid || errors.some(({ path }) => path?.[2] === 'commits')) {
|
||||
throw new Error('Missing PR head')
|
||||
}
|
||||
cursors.add(cursor)
|
||||
const page = await readQuery(repository, number, options, CHECKS_QUERY, cursor)
|
||||
if (page.failure) {
|
||||
throw page.failure.error
|
||||
}
|
||||
const parsedPage = checksEnvelopeSchema.safeParse(page.payload)
|
||||
if (!parsedPage.success) {
|
||||
return undefined
|
||||
}
|
||||
const next = parsedPage.data
|
||||
if (
|
||||
next.errors?.length ||
|
||||
next.data.repository.pullRequest.number !== number ||
|
||||
next.data.repository.pullRequest.commits.nodes[0]?.commit.oid !==
|
||||
pr.commits.nodes[0]?.commit.oid
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
contexts = next.data.repository.pullRequest.commits.nodes[0]?.commit.statusCheckRollup?.contexts
|
||||
if (!contexts) {
|
||||
return undefined
|
||||
}
|
||||
statusCheckRollup.push(...contexts.nodes.flatMap((node) => (node ? [node] : [])))
|
||||
statusCheckRollup = await collectPRCheckRollup(
|
||||
{
|
||||
number: pr.number,
|
||||
headRefOid: pr.headRefOid,
|
||||
commits: prCheckRollupCommitsSchema.parse(pr.commits)
|
||||
},
|
||||
createPRCheckRollupPageReader(repository, number, options)
|
||||
)
|
||||
} catch {
|
||||
// Metadata remains usable when complete checks need REST recovery.
|
||||
}
|
||||
const stack = stackSchema.safeParse(pr.stack)
|
||||
const entry = stackEntrySchema.safeParse(pr.stackEntry)
|
||||
@@ -248,8 +192,8 @@ export async function readPullRequestSidebarQuery(
|
||||
...metadata,
|
||||
baseRefOid: pr.baseRefOid ?? undefined,
|
||||
headRefOid: pr.headRefOid ?? undefined,
|
||||
statusCheckRollup,
|
||||
checksUnavailable: false,
|
||||
statusCheckRollup: statusCheckRollup ?? [],
|
||||
checksUnavailable: statusCheckRollup === undefined,
|
||||
stackMetadataChecked,
|
||||
stack:
|
||||
stackMetadataChecked && validStack
|
||||
|
||||
@@ -12,7 +12,7 @@ export const WORK_ITEM_PR_LIST_JSON_FIELDS =
|
||||
|
||||
// Keep first-paint reviewers in list payloads; defer costly checks, review decisions and merge metadata.
|
||||
export const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify'
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify'
|
||||
|
||||
/** Preserve API-provided avatars with logins; dotcom login-based URLs do not identify Enterprise avatars. */
|
||||
export function authorFieldsFromUnknown(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const { exec } = vi.hoisted(() => ({ exec: vi.fn() }))
|
||||
vi.mock('../git/command-runner/gh-exec-file', async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
ghExecFileAsync: exec
|
||||
}))
|
||||
vi.mock('./rate-limit', () => ({
|
||||
noteRepositoryRateLimitSpend: vi.fn(),
|
||||
repositoryRateLimitGuard: () => ({ blocked: false }),
|
||||
spendsSharedGitHubComQuota: () => false
|
||||
}))
|
||||
import { readCompletePRCheckObservation } from './client/check/complete-pr-check-observation'
|
||||
import { summarizePRCheckRollup } from '../../shared/source-control/checks/pr-check-status'
|
||||
|
||||
const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' }
|
||||
const sha = 'a'.repeat(40)
|
||||
const success = {
|
||||
__typename: 'CheckRun',
|
||||
status: 'COMPLETED',
|
||||
conclusion: 'SUCCESS',
|
||||
checkSuite: { databaseId: 7 }
|
||||
}
|
||||
const failure = { ...success, conclusion: 'FAILURE' }
|
||||
const connection = (nodes: unknown[], cursor: string | null = null) => ({
|
||||
nodes,
|
||||
pageInfo: { hasNextPage: cursor !== null, endCursor: cursor }
|
||||
})
|
||||
function page(
|
||||
contexts: unknown[],
|
||||
suites: unknown[] = [],
|
||||
checksCursor: string | null = null,
|
||||
suitesCursor: string | null = null,
|
||||
head = sha
|
||||
) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 42,
|
||||
headRefOid: head,
|
||||
commits: {
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: head,
|
||||
statusCheckRollup: { contexts: connection(contexts, checksCursor) },
|
||||
checkSuites: connection(suites, suitesCursor)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const read = () => readCompletePRCheckObservation(repository, 42, sha, { cwd: '/repo' })
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
|
||||
it('counts a hidden failure and suite-only blocker after both first pages without counting a represented suite twice', async () => {
|
||||
exec
|
||||
.mockResolvedValueOnce(
|
||||
page(
|
||||
Array.from({ length: 100 }, () => success),
|
||||
[{ databaseId: 7, conclusion: 'ACTION_REQUIRED' }],
|
||||
'checks-2',
|
||||
'suites-2'
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(page([failure]))
|
||||
.mockResolvedValueOnce(page([], [{ databaseId: 8, conclusion: 'ACTION_REQUIRED' }]))
|
||||
const rollup = await read()
|
||||
expect(summarizePRCheckRollup(rollup!)).toEqual({
|
||||
state: 'failure',
|
||||
total: 102,
|
||||
passed: 100,
|
||||
failed: 2,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
neutral: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('counts unnamed and name-colliding failed observations instead of using detail-list filtering', async () => {
|
||||
exec.mockResolvedValueOnce(
|
||||
page([
|
||||
success,
|
||||
{ ...failure, name: '' },
|
||||
{ __typename: 'StatusContext', context: 'same-name', state: 'FAILURE' }
|
||||
])
|
||||
)
|
||||
expect(summarizePRCheckRollup((await read())!)).toMatchObject({
|
||||
state: 'failure',
|
||||
total: 3,
|
||||
failed: 2
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['failed-page', 'changed-head', 'broken-cursor'])(
|
||||
'never certifies the successful prefix after %s',
|
||||
async (kind) => {
|
||||
exec.mockResolvedValueOnce(page([success], [], 'checks-2'))
|
||||
if (kind === 'failed-page') {
|
||||
exec.mockRejectedValueOnce(new Error('Unavailable'))
|
||||
} else {
|
||||
exec.mockResolvedValueOnce(
|
||||
page(
|
||||
[success],
|
||||
[],
|
||||
kind === 'broken-cursor' ? 'checks-2' : null,
|
||||
null,
|
||||
kind === 'changed-head' ? 'b'.repeat(40) : sha
|
||||
)
|
||||
)
|
||||
}
|
||||
exec.mockRejectedValue(new Error('REST unavailable'))
|
||||
expect(await read()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('recovers every REST page and endpoint before publishing a full verdict', async () => {
|
||||
exec.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('graphql')) {
|
||||
throw new Error('GraphQL unavailable')
|
||||
}
|
||||
const path = args.at(-1)!
|
||||
expect(args).not.toContain('--cache')
|
||||
if (path.includes('/check-runs?')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
total_count: 101,
|
||||
check_runs: path.endsWith('page=1')
|
||||
? Array.from({ length: 100 }, () => ({
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
check_suite: { id: 7 }
|
||||
}))
|
||||
: [{ status: 'completed', conclusion: 'cancelled', check_suite: { id: 7 } }]
|
||||
})
|
||||
}
|
||||
}
|
||||
if (path.includes('/status?')) {
|
||||
return {
|
||||
stdout: JSON.stringify({ total_count: 1, statuses: [{ state: 'failure', context: '' }] })
|
||||
}
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
total_count: 1,
|
||||
check_suites: [{ id: 8, status: 'completed', conclusion: 'action_required' }]
|
||||
})
|
||||
}
|
||||
})
|
||||
expect(summarizePRCheckRollup((await read())!)).toEqual({
|
||||
state: 'failure',
|
||||
total: 103,
|
||||
passed: 100,
|
||||
failed: 3,
|
||||
cancelled: 1,
|
||||
pending: 0,
|
||||
neutral: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes exhausted empty REST collections from a missing required subread', async () => {
|
||||
let failStatuses = false
|
||||
exec.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('graphql') || (failStatuses && args.at(-1)!.includes('/status?'))) {
|
||||
throw new Error('Unavailable')
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({ total_count: 0, check_runs: [], statuses: [], check_suites: [] })
|
||||
}
|
||||
})
|
||||
expect(await read()).toEqual([])
|
||||
failStatuses = true
|
||||
expect(await read()).toBeUndefined()
|
||||
})
|
||||
@@ -644,7 +644,7 @@ it.each(['error', 'absent'] as const)(
|
||||
it('recovers visible merged review through Bob complete membership iteration after Alice error and reuses Bob confirmation', async () => {
|
||||
request = (args, child, token) =>
|
||||
queueMicrotask(() => {
|
||||
const membership = args.find((arg) => arg.includes('/commits/'))
|
||||
const membership = args.find((arg) => arg.includes('/commits/') && arg.includes('/pulls?'))
|
||||
if (membership && token === 'Alice-token') {
|
||||
child.stderr?.emit('data', Buffer.from('request failed'))
|
||||
child.emit('exit', 1, null)
|
||||
@@ -697,8 +697,11 @@ it('recovers visible merged review through Bob complete membership iteration aft
|
||||
})
|
||||
expect(
|
||||
requests
|
||||
.filter((r) => r.args.some((arg) => arg.includes('/commits/')))
|
||||
.map((r) => ({ endpoint: r.args.find((arg) => arg.includes('/commits/')), token: r.token }))
|
||||
.filter((r) => r.args.some((arg) => arg.includes('/commits/') && arg.includes('/pulls?')))
|
||||
.map((r) => ({
|
||||
endpoint: r.args.find((arg) => arg.includes('/commits/') && arg.includes('/pulls?')),
|
||||
token: r.token
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
endpoint: 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1',
|
||||
|
||||
@@ -12,7 +12,8 @@ vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
}))
|
||||
vi.mock('./rate-limit', () => ({
|
||||
noteRepositoryRateLimitSpend: spend,
|
||||
repositoryRateLimitGuard: () => ({ blocked: false })
|
||||
repositoryRateLimitGuard: () => ({ blocked: false }),
|
||||
spendsSharedGitHubComQuota: () => false
|
||||
}))
|
||||
|
||||
import { getPRByNumber, lookupPRByNumber } from './client/lookup/pr-number-lookup'
|
||||
@@ -33,9 +34,17 @@ function commits(checks: unknown[] = [successCheck], endCursor?: string, oid = '
|
||||
{
|
||||
commit: {
|
||||
oid,
|
||||
checkSuites: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
|
||||
statusCheckRollup: {
|
||||
contexts: {
|
||||
nodes: checks,
|
||||
nodes: checks.map((raw) =>
|
||||
raw &&
|
||||
typeof raw === 'object' &&
|
||||
'__typename' in raw &&
|
||||
raw.__typename === 'CheckRun'
|
||||
? { ...raw, checkSuite: { databaseId: 7 } }
|
||||
: raw
|
||||
),
|
||||
pageInfo: { hasNextPage: Boolean(endCursor), endCursor: endCursor ?? null }
|
||||
}
|
||||
}
|
||||
@@ -136,9 +145,7 @@ describe('sidebar PR query', () => {
|
||||
).resolves.toMatchObject({
|
||||
number: 42
|
||||
})
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
expect(exec.mock.calls[1][0]).toContain('repos/acme/widgets/pulls/42')
|
||||
expect(spend.mock.calls.filter(([, bucket]) => bucket === 'graphql')).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -166,18 +173,18 @@ describe('sidebar PR query', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('uses REST recovery directly if a continuation request fails', async () => {
|
||||
it('retains PR metadata but never certifies a successful prefix after continuation failure', async () => {
|
||||
exec
|
||||
.mockResolvedValueOnce(response({ ...fields, commits: commits([successCheck], 'next') }))
|
||||
.mockRejectedValueOnce(new Error('ETIMEDOUT'))
|
||||
.mockResolvedValueOnce(restResponse())
|
||||
.mockRejectedValue(new Error('Unavailable'))
|
||||
await expect(
|
||||
getPRByNumber(repository, 42, options, 'local:host', undefined, { includeStackSummary: true })
|
||||
).resolves.toMatchObject({
|
||||
number: 42
|
||||
number: 42,
|
||||
title: fields.title,
|
||||
checksUnavailable: true,
|
||||
statusCheckRollup: []
|
||||
})
|
||||
expect(exec).toHaveBeenCalledTimes(3)
|
||||
expect(exec.mock.calls[2][0]).toContain('repos/acme/widgets/pulls/42')
|
||||
})
|
||||
|
||||
it('keeps capability hydration on the PR base before attaching its stack summary', async () => {
|
||||
@@ -300,7 +307,9 @@ describe('sidebar PR query', () => {
|
||||
)
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(response({ number: 42, commits: commits([null, failingCheck]) }))
|
||||
.mockResolvedValueOnce(
|
||||
response({ number: 42, headRefOid: fields.headRefOid, commits: commits([failingCheck]) })
|
||||
)
|
||||
const data = await getPRByNumber(repository, 42, options, 'local:host', undefined, {
|
||||
includeStackSummary: true
|
||||
})
|
||||
@@ -312,26 +321,29 @@ describe('sidebar PR query', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ number: 42, commits: commits([], 'next') },
|
||||
{ number: 42, commits: commits([], undefined, 'c'.repeat(40)) }
|
||||
{ number: 42, headRefOid: fields.headRefOid, commits: commits([], 'next') },
|
||||
{ number: 42, headRefOid: fields.headRefOid, commits: commits([], undefined, 'c'.repeat(40)) }
|
||||
])(
|
||||
'recovers through the rich CLI if checks pagination stops advancing or the commit changes',
|
||||
'keeps metadata without a certified summary when checks pagination stops advancing or the commit changes',
|
||||
async (nextPage) => {
|
||||
exec
|
||||
.mockResolvedValueOnce(response({ ...fields, commits: commits([successCheck], 'next') }))
|
||||
.mockResolvedValueOnce(response(nextPage))
|
||||
.mockResolvedValueOnce(legacyResponse())
|
||||
.mockRejectedValue(new Error('Unavailable'))
|
||||
const data = await getPRByNumber(repository, 42, options, 'local:host', undefined, {
|
||||
includeStackSummary: true
|
||||
})
|
||||
expect(deriveCheckStatus(data?.statusCheckRollup)).toBe('success')
|
||||
expect(exec).toHaveBeenCalledTimes(3)
|
||||
expect(exec.mock.calls[2][0].slice(0, 2)).toEqual(['pr', 'view'])
|
||||
expect(data).toMatchObject({
|
||||
number: 42,
|
||||
title: fields.title,
|
||||
checksUnavailable: true,
|
||||
statusCheckRollup: []
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves known branch discovery metadata without adding another query', async () => {
|
||||
exec.mockResolvedValueOnce(legacyResponse())
|
||||
it('preserves known branch discovery metadata while reusing the combined exact check page', async () => {
|
||||
exec.mockResolvedValueOnce(response())
|
||||
const known = {
|
||||
...fields,
|
||||
statusCheckRollup: [],
|
||||
@@ -341,8 +353,7 @@ describe('sidebar PR query', () => {
|
||||
}
|
||||
const data = await getPRByNumber(repository, 42, options, 'local:host', known)
|
||||
expect(data).toMatchObject({ headDefaultBranchName: 'trunk', stackMetadataChecked: true })
|
||||
expect(exec).toHaveBeenCalledTimes(1)
|
||||
expect(exec.mock.calls[0][0].slice(0, 2)).toEqual(['pr', 'view'])
|
||||
expect(exec.mock.calls[0][0].slice(0, 2)).toEqual(['api', 'graphql'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -370,7 +381,6 @@ describe('sidebar PR query', () => {
|
||||
usedExactNumberLookup: true
|
||||
})
|
||||
expect(data.stackMetadataChecked).toBe(true)
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
expect(exec.mock.calls[1][0]).toContain('repos/acme/widgets/pulls/42')
|
||||
}
|
||||
)
|
||||
@@ -407,12 +417,11 @@ describe('sidebar PR query', () => {
|
||||
).resolves.toMatchObject({
|
||||
title: 'CLI fallback'
|
||||
})
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
expect(exec.mock.calls[1][0].slice(0, 2)).toEqual(['pr', 'view'])
|
||||
})
|
||||
|
||||
it.each(['github.corp.example', undefined])(
|
||||
'keeps the existing read for host %s',
|
||||
'uses host-scoped metadata and the complete collector for host %s',
|
||||
async (host) => {
|
||||
exec.mockResolvedValueOnce({ stdout: JSON.stringify({ ...fields, statusCheckRollup: [] }) })
|
||||
await expect(
|
||||
@@ -420,7 +429,6 @@ describe('sidebar PR query', () => {
|
||||
includeStackSummary: true
|
||||
})
|
||||
).resolves.toMatchObject({ number: 42 })
|
||||
expect(exec).toHaveBeenCalledTimes(1)
|
||||
expect(exec.mock.calls[0][0].slice(0, 2)).toEqual(['pr', 'view'])
|
||||
}
|
||||
)
|
||||
@@ -450,3 +458,113 @@ describe('sidebar PR query', () => {
|
||||
expect(exec.mock.calls[0][1]).toEqual({ ...options, ...execution, host: 'github.com' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ checks: [null], errors: undefined },
|
||||
{
|
||||
checks: [{ __typename: 'CheckRun', status: 'FUTURE_STATUS', conclusion: null }],
|
||||
errors: undefined
|
||||
},
|
||||
{
|
||||
checks: [successCheck],
|
||||
errors: [
|
||||
{ path: ['repository', 'pullRequest', 'commits', 'nodes', 0, 'commit', 'statusCheckRollup'] }
|
||||
]
|
||||
}
|
||||
])(
|
||||
'keeps valid metadata and stack when strict checks are unavailable: %j',
|
||||
async ({ checks, errors }) => {
|
||||
exec.mockResolvedValueOnce(
|
||||
response(
|
||||
{
|
||||
...fields,
|
||||
commits: commits(checks),
|
||||
stack: { number: 7, size: 2, baseRefName: 'main' },
|
||||
stackEntry: { position: 1 }
|
||||
},
|
||||
errors
|
||||
)
|
||||
)
|
||||
await expect(readPullRequestSidebarQuery(repository, 42, options)).resolves.toMatchObject({
|
||||
title: fields.title,
|
||||
checksUnavailable: true,
|
||||
stackMetadataChecked: true,
|
||||
stack: { number: 7, size: 2, position: 1 }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([1, 10])(
|
||||
'reuses combined exact hydration with independent context and suite populations at N=%i',
|
||||
async (n) => {
|
||||
const contextPages = 2 * n
|
||||
const suitePages = 3 * n
|
||||
exec.mockImplementation(async (args: string[]) => {
|
||||
const contextCursor = args.find((arg) => arg.startsWith('checksCursor='))
|
||||
const suiteCursor = args.find((arg) => arg.startsWith('suitesCursor='))
|
||||
const contextIndex = contextCursor ? Number(contextCursor.split('=')[1]) : 0
|
||||
const suiteIndex = suiteCursor ? Number(suiteCursor.split('=')[1]) : 0
|
||||
const lastContext = contextIndex === contextPages - 1
|
||||
const commit = {
|
||||
oid: fields.headRefOid,
|
||||
...(!suiteCursor
|
||||
? {
|
||||
statusCheckRollup: {
|
||||
contexts: {
|
||||
nodes: [
|
||||
lastContext ? { __typename: 'StatusContext', state: 'FAILURE' } : successCheck
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: !lastContext,
|
||||
endCursor: lastContext ? null : String(contextIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(!contextCursor
|
||||
? {
|
||||
checkSuites: {
|
||||
nodes: [{ databaseId: suiteIndex + 1, conclusion: 'SUCCESS' }],
|
||||
pageInfo: {
|
||||
hasNextPage: suiteIndex < suitePages - 1,
|
||||
endCursor: suiteIndex < suitePages - 1 ? String(suiteIndex + 1) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
return response({ ...fields, commits: { nodes: [{ commit }] } })
|
||||
})
|
||||
const known = {
|
||||
...fields,
|
||||
stack: undefined,
|
||||
statusCheckRollup: [],
|
||||
headDefaultBranchName: 'trunk'
|
||||
}
|
||||
const observed = await getPRByNumber(repository, 42, options, 'local:host', known)
|
||||
expect(observed).toMatchObject({ headDefaultBranchName: 'trunk', checksUnavailable: false })
|
||||
expect(observed?.statusCheckRollup).toHaveLength(contextPages)
|
||||
expect(observed?.statusCheckRollup?.at(-1)).toEqual({
|
||||
__typename: 'StatusContext',
|
||||
state: 'FAILURE'
|
||||
})
|
||||
expect(exec).toHaveBeenCalledTimes(1 + (contextPages - 1) + (suitePages - 1))
|
||||
expect(exec.mock.calls.every(([args]) => args[0] === 'api' && args[1] === 'graphql')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('retains the rich exact metadata path when the optional combined read fails', async () => {
|
||||
exec
|
||||
.mockRejectedValueOnce(new Error('unsupported optional query'))
|
||||
.mockResolvedValueOnce(legacyResponse())
|
||||
await expect(
|
||||
getPRByNumber(repository, 42, options, 'local:host', {
|
||||
...fields,
|
||||
stack: undefined,
|
||||
statusCheckRollup: [],
|
||||
headDefaultBranchName: 'trunk'
|
||||
})
|
||||
).resolves.toMatchObject({ reviewDecision: null, headDefaultBranchName: 'trunk' })
|
||||
expect(exec.mock.calls[1][0].slice(0, 2)).toEqual(['pr', 'view'])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user