mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
Prefer exact PR lookups and stale sidebar refresh
Prefer exact linked PR lookup when safe, add stale-while-revalidate for sidebar hosted review metadata, and preserve branch discovery correctness for stale linked PR metadata.
This commit is contained in:
@@ -50,10 +50,16 @@ 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 },
|
||||
classifyGhError: (stderr: string) => {
|
||||
const lower = stderr.toLowerCase()
|
||||
if (lower.includes('not found') || stderr.includes('HTTP 404')) {
|
||||
return { type: 'not_found', message: stderr }
|
||||
}
|
||||
if (lower.includes('rate limit')) {
|
||||
return { type: 'rate_limited', message: stderr }
|
||||
}
|
||||
return { type: 'unknown', message: stderr }
|
||||
},
|
||||
parseGitHubOwnerRepo: (remoteUrl: string) => {
|
||||
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
||||
return match ? { owner: match[1], repo: match[2] } : null
|
||||
@@ -146,6 +152,302 @@ describe('getPRForBranch', () => {
|
||||
expect(pr?.mergeable).toBe('MERGEABLE')
|
||||
})
|
||||
|
||||
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({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
title: 'Linked PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/99',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'someone/fix',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'linked-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
'99',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(pr).toMatchObject({
|
||||
number: 99,
|
||||
title: 'Linked PR',
|
||||
state: 'open',
|
||||
headSha: 'linked-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses branch discovery when exact linked PR metadata resolves to a different PR', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 99,
|
||||
title: 'Stale linked PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/99',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'someone/other-work',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'stale-linked-head'
|
||||
})
|
||||
})
|
||||
.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: 'current-worktree-head'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--head',
|
||||
'feature/test',
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(pr?.number).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to branch discovery when exact 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'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
'99',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--head',
|
||||
'feature/test',
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(pr?.number).toBe(42)
|
||||
})
|
||||
|
||||
it('continues to branch discovery when exact linked PR REST fallback also misses', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('GraphQL: could not resolve to PullRequest'))
|
||||
.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Branch PR after stale linked miss',
|
||||
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(2, ['api', 'repos/acme/widgets/pulls/99'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--head',
|
||||
'feature/test',
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(pr?.number).toBe(42)
|
||||
})
|
||||
|
||||
it('continues to branch discovery when exact linked PR REST fallback has an unclassified failure', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('GraphQL: server exploded'))
|
||||
.mockRejectedValueOnce(new Error('HTTP 500: server error'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Branch PR after exact lookup outage',
|
||||
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(2, ['api', 'repos/acme/widgets/pulls/99'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--head',
|
||||
'feature/test',
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(pr?.number).toBe(42)
|
||||
})
|
||||
|
||||
it('does not spend branch discovery calls when exact linked PR REST fallback is rate limited', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.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(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
'99',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to REST branch lookup when gh pr list is GraphQL rate limited', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
@@ -354,8 +656,8 @@ 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
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
@@ -374,9 +676,23 @@ describe('getPRForBranch', () => {
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(3, ['api', 'repos/acme/widgets/pulls/99'], {
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
'99',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(pr).toMatchObject({
|
||||
number: 99,
|
||||
state: 'merged',
|
||||
|
||||
+89
-23
@@ -1302,20 +1302,84 @@ async function getRestPRByNumber(
|
||||
return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest)
|
||||
}
|
||||
|
||||
async function getPRByNumber(
|
||||
ownerRepo: OwnerRepo,
|
||||
number: number,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
PR_LOOKUP_JSON_FIELDS
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return JSON.parse(stdout) as PullRequestLookupData
|
||||
} catch (err) {
|
||||
// Why: deleted or manually edited linked PR metadata should fall back to
|
||||
// branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
|
||||
if (isNotFoundGhError(err)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await getRestPRByNumber(ownerRepo, number, ghOptions)
|
||||
} catch (restErr) {
|
||||
if (isNotFoundGhError(restErr)) {
|
||||
return null
|
||||
}
|
||||
if (!shouldStopAfterExactLookupError(restErr)) {
|
||||
return null
|
||||
}
|
||||
throw restErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exactPRMatchesWorktreeHead(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
data: PullRequestLookupData,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
if (!branchName || data.headRefName === branchName) {
|
||||
return true
|
||||
}
|
||||
if (connectionId || !data.headRefOid) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: repoPath })
|
||||
return stdout.trim() === data.headRefOid
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundGhError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
return classifyGhError(stderr).type === 'not_found'
|
||||
}
|
||||
|
||||
function shouldStopAfterExactLookupError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
const type = classifyGhError(stderr).type
|
||||
return type === 'rate_limited' || type === 'permission_denied' || type === 'network_error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR info for a given branch using gh CLI.
|
||||
* Returns null if gh is not installed, or no PR exists for the branch.
|
||||
*
|
||||
* When `linkedPRNumber` is provided and the branch lookup yields nothing,
|
||||
* falls back to looking up the PR by number. This handles "create from PR"
|
||||
* worktrees, whose branch is a fresh local branch (not the PR's head ref) —
|
||||
* the branch-keyed lookup misses, but the user still expects the linked PR
|
||||
* to surface on the worktree card.
|
||||
* When `linkedPRNumber` is provided and the repo identity is known, starts
|
||||
* with a direct PR-number lookup. This handles "create from PR" worktrees,
|
||||
* whose branch is a fresh local branch, and avoids spending a branch-list
|
||||
* request before asking for the exact PR the worktree already stores.
|
||||
*/
|
||||
export async function getPRForBranch(
|
||||
repoPath: string,
|
||||
@@ -1332,11 +1396,22 @@ export async function getPRForBranch(
|
||||
try {
|
||||
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
|
||||
let data: PullRequestLookupData | null = null
|
||||
let exactLinkedData: PullRequestLookupData | null = null
|
||||
|
||||
if (ownerRepo && typeof linkedPRNumber === 'number') {
|
||||
data = await getPRByNumber(ownerRepo, linkedPRNumber, ghOptions)
|
||||
if (data && !(await exactPRMatchesWorktreeHead(repoPath, branchName, data, connectionId))) {
|
||||
// Why: linked PR metadata is user-editable. If the stored number still
|
||||
// resolves but no longer matches this worktree, let branch lookup correct it.
|
||||
exactLinkedData = data
|
||||
data = 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 (!data && branchName) {
|
||||
if (ownerRepo) {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
@@ -1375,33 +1450,24 @@ export async function getPRForBranch(
|
||||
}
|
||||
}
|
||||
|
||||
if (!data && typeof linkedPRNumber === 'number') {
|
||||
const args = ownerRepo
|
||||
? [
|
||||
'pr',
|
||||
'view',
|
||||
String(linkedPRNumber),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
PR_LOOKUP_JSON_FIELDS
|
||||
]
|
||||
: ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS]
|
||||
if (!data && !ownerRepo && typeof linkedPRNumber === 'number') {
|
||||
const args = ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS]
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(args, ghOptions)
|
||||
data = JSON.parse(stdout)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// 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 =
|
||||
ownerRepo && !isNotFoundGhError(err)
|
||||
? await getRestPRByNumber(ownerRepo, linkedPRNumber, ghOptions)
|
||||
: null
|
||||
data = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!data && exactLinkedData) {
|
||||
data = exactLinkedData
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user