Split github client (#15214)

* refactor(github-client): reorganize client into lifecycle folders

* refactor(github-client): extract PR refresh data and outcome assembly

Separate the derived data calculation and outcome assembly logic from
branch-lookup-resolution into dedicated modules for better separation of
concerns. Modernize type import syntax and format exports consistently.

* refactor(github-client): improve error handling and resilience

Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks.
Cache failures now use shorter TTLs for faster recovery. PR operations have
dedicated error classification. GraphQL mutations track rate limit usage to prevent
quota exhaustion. Data validation improved to reject spurious values.

* Extract check rerun error classification with operation context

Create classifyRerunChecksError() to provide operation-specific error
messages when check reruns fail. This replaces generic GitHub error
copy with context appropriate to what the user attempted (rerun
checks). Follows the pattern of classifyListPrsError and improves
error handling by delegating extraction to extractExecError.

* Make check-rerun not-found error message resource-neutral

Error handling for failed check reruns now covers both workflow-run
reruns and standalone check-run rerequests. Tests verify the neutral
message works for both scenarios.
This commit is contained in:
Jinjing
2026-08-17 21:18:44 -07:00
committed by GitHub
parent 604169f4af
commit 63dbf12d14
62 changed files with 6314 additions and 5601 deletions
@@ -0,0 +1,28 @@
import type { GitHubViewer } from '../../../../shared/github/pull-request-types'
import { execFileAsync, acquire, release } from '../../gh-utils'
/**
* Get the authenticated GitHub viewer when gh is available and logged in.
* Returns null when gh is unavailable, unauthenticated, or the lookup fails.
*/
export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
await acquire()
try {
const { stdout } = await execFileAsync(
'gh',
['api', 'user', '--jq', '{login: .login, email: .email}'],
{ encoding: 'utf-8' }
)
const viewer = JSON.parse(stdout) as { login?: string; email?: string | null }
if (!viewer.login?.trim()) {
return null
}
return {
login: viewer.login.trim(),
email: viewer.email?.trim() || null
}
} catch {
return null
} finally {
release()
}
}
@@ -0,0 +1,279 @@
import type { PRComment } from '../../../../shared/github/comment-types'
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from '../../comment-reactions'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
import { REVIEW_THREADS_QUERY } from './pr-review-threads-query'
/**
* Get all comments on a PR — both top-level conversation comments and inline
* review comments (including suggestions). Uses GraphQL for review threads
* to get resolution status, REST for issue-level comments.
*/
export async function getPRComments(
repoPath: string,
prNumber: number,
options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRComment[]> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
options?.prRepo,
connectionId,
localGitOptions
)
if (connectionId && !ownerRepo) {
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
if (ownerRepo) {
await assertRateLimitBudget('core', ownerRepo, ghOptions)
}
await acquire()
try {
if (ownerRepo) {
// Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
// Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero.
const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null>
if (reviewThreadsGuard.blocked) {
reviewThreadsFetch = Promise.resolve(null)
} else {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
reviewThreadsFetch = ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${REVIEW_THREADS_QUERY}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`,
'-F',
`pr=${prNumber}`
],
ghOptions
)
}
const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([
ghExecFileAsync(
['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`],
ghOptions
),
reviewThreadsFetch,
// Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads.
ghExecFileAsync(
['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`],
ghOptions
)
])
noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions)
// Parse issue comments (REST)
type RESTComment = {
id: number
user: { login: string; avatar_url: string; type?: string } | null
body: string
created_at: string
html_url: string
}
let issueComments: PRComment[] = []
if (issueResult.status === 'fulfilled') {
issueComments = (JSON.parse(issueResult.value.stdout) as RESTComment[]).map(
(c): PRComment => ({
id: c.id,
author: c.user?.login ?? 'ghost',
authorAvatarUrl: c.user?.avatar_url ?? '',
body: c.body ?? '',
createdAt: c.created_at,
url: c.html_url,
isBot: c.user?.type === 'Bot'
})
)
} else {
console.warn('Failed to fetch issue comments:', issueResult.reason)
}
// Parse review threads (GraphQL)
type GQLThread = {
id: string
isResolved: boolean
line: number | null
startLine: number | null
originalLine: number | null
originalStartLine: number | null
comments: {
nodes: {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
path: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}[]
}
}
type GQLIssueComment = {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}
let graphQLReviewSummaries: PRComment[] | undefined
const reviewComments: PRComment[] = []
if (threadsResult.status === 'fulfilled' && threadsResult.value) {
const threadsData = JSON.parse(threadsResult.value.stdout) as {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: { nodes?: GQLThread[] | null } | null
comments?: { nodes?: GQLIssueComment[] | null } | null
reviews?: { nodes?: GQLIssueComment[] | null } | null
} | null
} | null
} | null
}
// Why: graphql can exit 0 with data.repository null plus an errors array (scopes, field-level denial);
// dereferencing it would throw and drop the REST halves fetched alongside it.
const pullRequest = threadsData.data?.repository?.pullRequest
if (!pullRequest) {
console.warn('Review threads response missing pullRequest; keeping REST results')
}
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map(
(c): PRComment => ({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups)
})
)
if (graphQLIssueComments.length > 0) {
issueComments = graphQLIssueComments
}
// Why: leave undefined when the payload is incomplete so the REST review summaries stay in use.
graphQLReviewSummaries = pullRequest
? (pullRequest.reviews?.nodes ?? [])
.filter((review) => review.body?.trim())
.map(
(review): PRComment => ({
id: review.databaseId,
author: review.author?.login ?? 'ghost',
authorAvatarUrl: review.author?.avatarUrl ?? '',
body: review.body,
createdAt: review.createdAt,
url: review.url,
isBot: review.author?.__typename === 'Bot',
reactionSubjectId: review.id,
reactions: mapGraphQLReactionGroups(review.reactionGroups)
})
)
: undefined
const threads = pullRequest?.reviewThreads?.nodes ?? []
for (const thread of threads) {
for (const c of thread.comments.nodes) {
reviewComments.push({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups),
path: c.path,
threadId: thread.id,
isResolved: thread.isResolved,
isOutdated: thread.line == null,
// Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers.
line: thread.line ?? thread.originalLine ?? undefined,
startLine: thread.startLine ?? thread.originalStartLine ?? undefined
})
}
}
} else {
if (threadsResult.status === 'rejected') {
console.warn('Failed to fetch review threads:', threadsResult.reason)
}
}
// Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise.
type RESTReview = {
id: number
user: { login: string; avatar_url: string; type?: string } | null
body: string
state: string
submitted_at: string
html_url: string
}
let reviewSummaries: PRComment[] = []
if (graphQLReviewSummaries) {
reviewSummaries = graphQLReviewSummaries
} else if (reviewsResult.status === 'fulfilled') {
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
.filter((r) => r.body?.trim())
.map(
(r): PRComment => ({
id: r.id,
author: r.user?.login ?? 'ghost',
authorAvatarUrl: r.user?.avatar_url ?? '',
body: r.body,
createdAt: r.submitted_at,
url: r.html_url,
isBot: r.user?.type === 'Bot'
})
)
} else {
console.warn('Failed to fetch review summaries:', reviewsResult.reason)
}
const all = [...issueComments, ...reviewComments, ...reviewSummaries]
all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return all
}
// Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments)
const { stdout } = await ghExecFileAsync(
['pr', 'view', String(prNumber), '--json', 'comments'],
ghOptions
)
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
const data = JSON.parse(stdout) as {
comments: {
author: { login: string }
body: string
createdAt: string
url: string
}[]
}
return (data.comments ?? []).map((c, i) => ({
id: i,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url ?? ''
}))
} catch (err) {
console.warn('getPRComments failed:', err)
return []
} finally {
release()
}
}
@@ -0,0 +1,144 @@
import type { IssueSourcePreference } from '../../../../shared/repo-types'
import { acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
import {
resolveGitHubApiRepository,
resolveGitHubApiRepositoryCandidates,
resolveIssueGitHubApiRepositorySource,
type GitHubApiRepository
} from '../../github-api-repository'
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
import type { MainWorkItem } from './../map/work-item-field-coercion'
import {
fetchIssueWorkItem,
fetchPullRequestWorkItem,
fetchPullRequestWorkItemFromCandidates
} from './work-item-fetch'
export async function getWorkItem(
repoPath: string,
number: number,
type?: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<MainWorkItem | null> {
await acquire()
try {
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
// must share that preference so origin/upstream toggles cannot disagree.
if (type === 'issue') {
const { source } = await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
// (same fail-closed rule as origin-pinned PR candidate resolution).
if (!source && preference === 'origin') {
return null
}
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
}
if (type === 'pr') {
return await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
)
}
try {
const { source } = await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
if (source || preference !== 'origin') {
const issue = await fetchIssueWorkItem(
repoPath,
source,
number,
connectionId,
localGitOptions
)
if (issue) {
return issue
}
}
} catch (err) {
// Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR.
const stderr = err instanceof Error ? err.message : String(err)
if (classifyGhError(stderr).type !== 'not_found') {
throw err
}
}
return await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
)
} catch {
return null
} finally {
release()
}
}
export async function getWorkItemByOwnerRepo(
repoPath: string,
ownerRepo: GitHubApiRepository,
number: number,
type: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const requestedHost = ownerRepo.host?.trim().toLowerCase()
const requestedRepository = requestedHost
? { ...ownerRepo, host: requestedHost }
: await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions)
if (!requestedRepository) {
return null
}
const { candidates } = await resolveGitHubApiRepositoryCandidates(
repoPath,
connectionId,
localGitOptions
)
const requestedKey = githubRepoIdentityKey(requestedRepository)
const matchedRepository = candidates.find(
(candidate) => githubRepoIdentityKey(candidate) === requestedKey
)
// Why: this lookup is reachable from pasted links. Restricting it to a
// configured remote prevents gh from sending credentials to an arbitrary host.
if (!matchedRepository) {
return null
}
await acquire()
try {
if (type === 'issue') {
return await fetchIssueWorkItem(
repoPath,
matchedRepository,
number,
connectionId,
localGitOptions
)
}
return await fetchPullRequestWorkItem(
repoPath,
matchedRepository,
number,
connectionId,
localGitOptions
)
} catch {
return null
} finally {
release()
}
}
+49
View File
@@ -0,0 +1,49 @@
import { execFileAsync, acquire, release } from '../../gh-utils'
export const ORCA_REPO = 'stablyai/orca'
/**
* Check if the authenticated user has starred the Orca repo.
* Returns true if starred, false if not, null if unable to determine (gh unavailable).
*/
export async function checkOrcaStarred(): Promise<boolean | null> {
await acquire()
try {
const { stdout, stderr } = await execFileAsync(
'gh',
['api', '--include', `user/starred/${ORCA_REPO}`],
{ encoding: 'utf-8' }
)
const response = `${stdout ?? ''}\n${stderr ?? ''}`
if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
return true
}
return null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 404 means the user hasn't starred — the only expected "no" answer
if (message.includes('HTTP 404')) {
return false
}
// Anything else (gh not installed, not authenticated, network issue)
return null
} finally {
release()
}
}
/**
* Star the Orca repo for the authenticated user.
*/
export async function starOrca(): Promise<boolean> {
await acquire()
try {
await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], {
encoding: 'utf-8'
})
return true
} catch {
return false
} finally {
release()
}
}
@@ -0,0 +1,70 @@
// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them).
export const REVIEW_THREADS_QUERY = `
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100) {
nodes {
id
isResolved
line
startLine
originalLine
originalStartLine
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
path
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
}
}
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
reviews(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
}
}
}`
@@ -0,0 +1,84 @@
import { ghExecFileAsync, acquire, release, type OwnerRepo } from '../../gh-utils'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../../../source-control/hosted-review-git-options'
import {
getGitHubApiRepositoryForRemote,
getOriginGitHubApiRepository,
githubRepositorySlugArg,
resolveGitHubRepoExecution,
type GitHubApiRepository
} from '../../github-api-repository'
import { hostedReviewLocalGitOptionArgs, sameOwnerRepo } from './../github-exec-scope'
export async function getRepoSlug(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<GitHubApiRepository | null> {
return getOriginGitHubApiRepository(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
}
/**
* Resolve a fork's upstream/parent owner/repo, or null when not a fork.
* Why: drives the fork indicator, and a same-name fork's avatar prefers the
* upstream owner (a renamed fork keeps its own owner).
* Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null.
*/
export async function getRepoUpstream(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<OwnerRepo | null> {
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
undefined,
connectionId,
localGitOptions
)
if (!origin) {
return null
}
const upstreamRemote = await getGitHubApiRepositoryForRemote(
repoPath,
'upstream',
connectionId,
localGitOptions
)
if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) {
return upstreamRemote
}
await acquire()
try {
// Why: positional slugs bypass the runner's --repo qualifier, so the slug
// itself must carry the Enterprise host.
const { stdout } = await ghExecFileAsync(
['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'],
// Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation.
{
...ghOptions,
timeout: 10_000
}
)
const data = JSON.parse(stdout) as {
isFork?: boolean
parent?: { name?: string; owner?: { login?: string } } | null
}
const owner = data.parent?.owner?.login
const repo = data.parent?.name
// Why: a fork parent lives on the same server as the fork.
return data.isFork && owner && repo
? { owner, repo, ...(origin.host ? { host: origin.host } : {}) }
: null
} catch {
return null
} finally {
release()
}
}
@@ -0,0 +1,199 @@
import type { IssueSourcePreference } from '../../../../shared/repo-types'
import {
ghExecFileAsync,
classifyGhError,
ghRepoExecOptions,
githubRepoContext,
type LocalGitExecOptions
} from '../../gh-utils'
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
import type { GhExecOptions } from './../github-exec-scope'
import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates'
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
import {
WORK_ITEM_PR_DETAIL_JSON_FIELDS,
usersFromUnknown,
latestReviewsFromUnknown,
type MainWorkItem
} from './../map/work-item-field-coercion'
import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item'
export async function fetchIssueWorkItem(
repoPath: string,
ownerRepo: GitHubApiRepository | null,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(ownerRepo)
}
if (ownerRepo) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
if ('pull_request' in item) {
return null
}
return mapIssueWorkItem(item)
}
if (connectionId) {
// Why: SSH-backed gh has no repository cwd. A bare lookup could honor the
// local process GH_REPO/GH_HOST and return an unrelated repository item.
return null
}
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'],
ghOptions
)
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty.
export const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
export async function fetchPullRequestReviewFields(
number: number,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
try {
const args = ownerRepo
? [
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_REVIEW_JSON_FIELDS
]
: ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
const { stdout } = await ghExecFileAsync(args, ghOptions)
const item = JSON.parse(stdout) as Record<string, unknown>
return {
...(item.reviewRequests !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests) }
: {}),
...(item.latestReviews !== undefined
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
: {})
}
} catch {
return {}
}
}
export async function fetchPullRequestWorkItem(
repoPath: string,
ownerRepo: GitHubApiRepository | null,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(ownerRepo)
}
if (ownerRepo) {
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_DETAIL_JSON_FIELDS
],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
const mapped = mapPullRequestWorkItem(item, ownerRepo)
// Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists.
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
try {
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
return {
...mapped,
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
...(mergeMetadata.autoMergeAllowed !== null
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
: {}),
...(mergeMetadata.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
}
} catch {
return mapped
}
} catch {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
const mapped = mapPullRequestWorkItem(
JSON.parse(stdout) as Record<string, unknown>,
ownerRepo
)
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
return { ...mapped, ...reviewFields }
}
}
if (connectionId) {
// Why: connection-backed gh cannot infer a repository from cwd. Refuse a
// bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup.
return null
}
const { stdout } = await ghExecFileAsync(
['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS],
ghOptions
)
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
export async function fetchPullRequestWorkItemFromCandidates(
repoPath: string,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<MainWorkItem | null> {
const candidates = await resolvePullRequestLookupCandidates(
repoPath,
preference,
connectionId,
localGitOptions
)
if (candidates.length === 0) {
if (preference === 'origin') {
return null
}
return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions)
}
for (const candidate of candidates) {
try {
return await fetchPullRequestWorkItem(
repoPath,
candidate,
number,
connectionId,
localGitOptions
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const classification = classifyGhError(message).type
if (classification !== 'not_found' && classification !== 'permission_denied') {
throw err
}
}
}
return null
}