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:
Brennan Benson
2026-11-05 10:39:54 -08:00
committed by GitHub
parent 384b445585
commit dd1784f55b
128 changed files with 26893 additions and 19460 deletions
@@ -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' }
)
+77 -6
View File
@@ -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
+88 -96
View File
@@ -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' }) })
+150
View File
@@ -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
}
}
}
+53 -72
View File
@@ -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'])
})