mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Fix PR lookup during GitHub GraphQL limits
This commit is contained in:
@@ -42,6 +42,10 @@ vi.mock('./gh-utils', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
ghRepoExecOptions: ghRepoExecOptionsMock,
|
||||
githubRepoContext: githubRepoContextMock,
|
||||
classifyGhError: (stderr: string) =>
|
||||
stderr.toLowerCase().includes('not found') || stderr.includes('HTTP 404')
|
||||
? { type: 'not_found', message: stderr }
|
||||
: { type: 'unknown', message: stderr },
|
||||
parseGitHubOwnerRepo: (remoteUrl: string) => {
|
||||
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
||||
return match ? { owner: match[1], repo: match[2] } : null
|
||||
@@ -120,6 +124,44 @@ describe('getPRForBranch', () => {
|
||||
expect(pr?.mergeable).toBe('MERGEABLE')
|
||||
})
|
||||
|
||||
it('falls back to REST branch lookup when gh pr list is GraphQL rate limited', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 43,
|
||||
title: 'REST branch lookup',
|
||||
state: 'open',
|
||||
html_url: 'https://github.com/acme/widgets/pull/43',
|
||||
updated_at: '2026-03-28T00:00:00Z',
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
head: { ref: 'feature/test', sha: 'rest-head-oid' },
|
||||
base: { ref: 'main', sha: 'rest-base-oid' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(pr).toMatchObject({
|
||||
number: 43,
|
||||
title: 'REST branch lookup',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/widgets/pull/43',
|
||||
checksStatus: 'neutral',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'rest-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce(null)
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
@@ -288,6 +330,39 @@ describe('getPRForBranch', () => {
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to REST number lookup when linked PR GraphQL lookup is rate limited', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
title: 'REST linked PR lookup',
|
||||
state: 'closed',
|
||||
merged_at: '2026-03-28T00:00:00Z',
|
||||
html_url: 'https://github.com/acme/widgets/pull/99',
|
||||
updated_at: '2026-03-28T00:00:00Z',
|
||||
draft: false,
|
||||
mergeable_state: 'clean',
|
||||
head: { ref: 'someone/fix', sha: 'linked-head-oid' },
|
||||
base: { ref: 'main', sha: 'linked-base-oid' }
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(3, ['api', 'repos/acme/widgets/pulls/99'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(pr).toMatchObject({
|
||||
number: 99,
|
||||
state: 'merged',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'linked-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves fork PR push target using the origin URL protocol', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
|
||||
+130
-50
@@ -990,6 +990,99 @@ export async function getWorkItemByOwnerRepo(
|
||||
}
|
||||
}
|
||||
|
||||
type PullRequestLookupData = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
url: string
|
||||
statusCheckRollup: unknown[]
|
||||
updatedAt: string
|
||||
isDraft?: boolean
|
||||
mergeable: string
|
||||
baseRefName?: string
|
||||
headRefName?: string
|
||||
baseRefOid?: string
|
||||
headRefOid?: string
|
||||
}
|
||||
|
||||
type RestPullRequest = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
html_url?: string
|
||||
url?: string
|
||||
updated_at?: string
|
||||
draft?: boolean
|
||||
merged_at?: string | null
|
||||
mergeable?: boolean | null
|
||||
mergeable_state?: string | null
|
||||
base?: { ref?: string; sha?: string }
|
||||
head?: { ref?: string; sha?: string }
|
||||
}
|
||||
|
||||
const PR_LOOKUP_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
|
||||
function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState {
|
||||
const mergeableState = pr.mergeable_state?.toLowerCase()
|
||||
if (mergeableState === 'dirty') {
|
||||
return 'CONFLICTING'
|
||||
}
|
||||
if (mergeableState === 'clean' || pr.mergeable === true) {
|
||||
return 'MERGEABLE'
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.merged_at ? 'MERGED' : pr.state,
|
||||
url: pr.html_url ?? pr.url ?? '',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: pr.updated_at ?? '',
|
||||
isDraft: pr.draft,
|
||||
mergeable: mapRestPRMergeable(pr),
|
||||
baseRefName: pr.base?.ref,
|
||||
headRefName: pr.head?.ref,
|
||||
baseRefOid: pr.base?.sha,
|
||||
headRefOid: pr.head?.sha
|
||||
}
|
||||
}
|
||||
|
||||
async function getRestPRForBranch(
|
||||
ownerRepo: OwnerRepo,
|
||||
branchName: string,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
const head = encodeURIComponent(`${ownerRepo.owner}:${branchName}`)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls?head=${head}&state=all&per_page=1`],
|
||||
ghOptions
|
||||
)
|
||||
const list = JSON.parse(stdout) as RestPullRequest[]
|
||||
const pr = list[0]
|
||||
return pr ? mapRestPullRequest(pr) : null
|
||||
}
|
||||
|
||||
async function getRestPRByNumber(
|
||||
ownerRepo: OwnerRepo,
|
||||
number: number,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
|
||||
ghOptions
|
||||
)
|
||||
return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest)
|
||||
}
|
||||
|
||||
function isNotFoundGhError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
return classifyGhError(stderr).type === 'not_found'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR info for a given branch using gh CLI.
|
||||
* Returns null if gh is not installed, or no PR exists for the branch.
|
||||
@@ -1014,54 +1107,44 @@ export async function getPRForBranch(
|
||||
await acquire()
|
||||
try {
|
||||
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
|
||||
let data: {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
url: string
|
||||
statusCheckRollup: unknown[]
|
||||
updatedAt: string
|
||||
isDraft?: boolean
|
||||
mergeable: string
|
||||
baseRefName?: string
|
||||
headRefName?: string
|
||||
baseRefOid?: string
|
||||
headRefOid?: string
|
||||
} | null = null
|
||||
let data: PullRequestLookupData | null = null
|
||||
|
||||
// During a rebase the worktree is in detached HEAD and branch is empty.
|
||||
// An empty --head filter causes gh to return an arbitrary PR — skip the
|
||||
// branch lookup and rely on the linkedPR fallback below if available.
|
||||
if (branchName) {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--head',
|
||||
branchName,
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
const list = JSON.parse(stdout) as NonNullable<typeof data>[]
|
||||
data = list[0] ?? null
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--head',
|
||||
branchName,
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
PR_LOOKUP_JSON_FIELDS
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
const list = JSON.parse(stdout) as PullRequestLookupData[]
|
||||
data = list[0] ?? null
|
||||
} catch (err) {
|
||||
// Why: `gh pr list/view` uses GraphQL and can hit that quota while
|
||||
// REST is still available. Falling back prevents a real PR from
|
||||
// rendering as "No pull request found" during GraphQL outages.
|
||||
if (!isNotFoundGhError(err)) {
|
||||
data = await getRestPRForBranch(ownerRepo, branchName, ghOptions)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
branchName,
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
['pr', 'view', branchName, '--json', PR_LOOKUP_JSON_FIELDS],
|
||||
ghOptions
|
||||
)
|
||||
data = JSON.parse(stdout)
|
||||
@@ -1077,24 +1160,21 @@ export async function getPRForBranch(
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
]
|
||||
: [
|
||||
'pr',
|
||||
'view',
|
||||
String(linkedPRNumber),
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
PR_LOOKUP_JSON_FIELDS
|
||||
]
|
||||
: ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS]
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(args, ghOptions)
|
||||
data = JSON.parse(stdout)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes
|
||||
// `gh pr view <number>` reject. Treat that as the no-PR case so
|
||||
// callers see the historical `null` semantics instead of a thrown
|
||||
// error every poll cycle.
|
||||
data = null
|
||||
data =
|
||||
ownerRepo && !isNotFoundGhError(err)
|
||||
? await getRestPRByNumber(ownerRepo, linkedPRNumber, ghOptions)
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user