Files
orca/src/main/github/client.ts
T
Jinjing c1e75477f3 Fix static analysis page stuck in loading state (#13674)
* Fix static analysis page stuck in loading state

- Bound check-details requests with 30s timeout, matching remote RPC budget
- Track request IDs to discard stale responses when context changes
- Propagate githubRepository through store and components for proper routing
- Add retry button for failed check-details loads
- Improve accessibility with ARIA labels for loading and error states

* Fix static analysis page stuck in loading state

When an open check-details tab's repository is removed, the loading
state would continue indefinitely because the fetch was still being
triggered. Prevent the fetch call in this scenario to unblock the UI.
Also migrates translation keys to obfuscated identifiers.

* Fix static analysis page stuck in loading state

Add deadline-based timeouts and request ID tracking to prevent stale responses
from freezing the checks panel. Include abort signal propagation throughout the
request chain and provide retry UI for failed check details loads.

* fix(checks): prevent loading state from getting stuck on retry

- Consolidate mount checks into a helper function
- Details now clear when a new request begins
- Add i18n strings for retry status
2026-08-10 23:11:45 -07:00

5519 lines
173 KiB
TypeScript

/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps acquire/release and error handling consistent. */
import type {
ClassifiedError,
GitPushTarget,
IssueSourcePreference,
ListWorkItemsResult,
PRInfo,
PRConflictSummary,
PRRefreshOutcome,
PRMergeableState,
PRReviewDecision,
PRCheckDetail,
PRCheckRunDetails,
GitHubCommentResult,
GitHubReactionContent,
GitHubPRReviewCommentInput,
PRComment,
GitHubViewer,
GitHubWorkItem,
GitHubPullRequestStateUpdate,
GitHubRerunPRChecksResult,
GitHubPRMergeMethod,
GitHubPRMergeMethodSettings,
GitHubPRStack
} from '../../shared/types'
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods'
import { summarizeProviderChecks } from '../../shared/provider-check-summary'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds'
import { classifyGitHubUnavailable } from '../../shared/github-api-availability'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import {
GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
sortWorkItemsByNumber
} from '../../shared/work-items'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { sliceCheckLogTail } from '../../shared/check-job-log-tail-slice'
import {
classifyPRRefreshError,
safePRRefreshErrorMessage
} from './pr-refresh-error-classification'
import { getPRConflictSummary } from './conflict-summary'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
import { splitRemoteBranchName } from '../../shared/git-effective-upstream'
import {
execFileAsync,
ghExecFileAsync,
gitExecFileAsync,
acquire,
release,
classifyGhError,
classifyListIssuesError,
classifyListPrsError,
ghRepoExecOptions,
githubRepoContext,
getRemoteUrlForRepo,
type LocalGitExecOptions,
type OwnerRepo
} from './gh-utils'
// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions.
import { extractExecError, parseRetryAfterMs } from '../git/exec-error'
import {
isCommitPartOfMergedPR,
type MergedPRCommitMembership
} from './merged-pr-commit-membership'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
hasHostedReviewLocalGitOptions,
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
import { readLocalGitConfigSignature } from './local-git-config-signature'
import {
getGitHubApiRepositoryForRemote,
getOriginGitHubApiRepository,
githubHostExecOptions,
githubRepositorySlugArg,
githubRepositoryWebHost,
resolveGitHubApiRepository,
resolveGitHubApiRepositoryCandidates,
resolveGitHubRepoExecution,
resolveIssueGitHubApiRepositorySource,
type GitHubRepoExecOptions,
type GitHubApiRepository
} from './github-api-repository'
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
export { _resetOwnerRepoCache } from './gh-utils'
export {
getIssue,
listIssues,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers
} from './issues'
import {
mapCheckRunRESTStatus,
mapCheckRunRESTConclusion,
mapCommitStatusRESTStatus,
mapCommitStatusRESTConclusion,
mapCheckStatus,
mapCheckConclusion,
mapPRState,
deriveCheckStatus
} from './mappers'
import {
mapGraphQLReactionGroups,
toGraphQLReactionContent,
type GitHubGraphQLReactionGroup
} from './comment-reactions'
import {
getRateLimit,
noteRepositoryRateLimitSpend,
repositoryRateLimitGuard,
spendsSharedGitHubComQuota,
type RateLimitBucketKind
} from './rate-limit'
import {
GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS,
GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE
} from '../../shared/github-check-details-deadline'
import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack'
type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal }
type HostedReviewLocalGitOptions = ReturnType<typeof getHostedReviewLocalGitOptions>
const ORCA_REPO = 'stablyai/orca'
const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5
// Why: each entry holds up to 16KB of log text; bound the cache so a long session can't grow it unbounded.
const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128
const prCheckLogTailCache = new Map<string, string | null>()
function hostedReviewLocalGitOptionArgs(
options: HostedReviewExecutionOptions = {}
): [] | [HostedReviewLocalGitOptions] {
return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : []
}
function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void {
prCheckLogTailCache.set(cacheKey, logTail)
while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
const oldestKey = prCheckLogTailCache.keys().next().value
if (oldestKey === undefined) {
break
}
prCheckLogTailCache.delete(oldestKey)
}
}
function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void {
if (signal?.aborted) {
throw error
}
}
function waitForCheckDetailsResolution<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
return Promise.reject(signal.reason)
}
return new Promise((resolve, reject) => {
const finish = (settle: () => void): void => {
signal.removeEventListener('abort', onAbort)
settle()
}
const onAbort = (): void => finish(() => reject(signal.reason))
signal.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => finish(() => resolve(value)),
(error) => finish(() => reject(error))
)
})
}
const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000
const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000
const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256
type GitHubRepositoryMergeMetadata = {
mergeQueueRequired: boolean | null
autoMergeAllowed: boolean | null
mergeMethodSettings?: GitHubPRMergeMethodSettings
}
const repositoryMergeMetadataCache = new Map<
string,
{ value: GitHubRepositoryMergeMetadata; expiresAt: number }
>()
const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000
const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512
const prStackSummaryCache = new Map<
string,
{ value: GitHubPRStack | undefined; expiresAt: number }
>()
const prStackSummaryInFlight = new Map<string, Promise<GitHubPRStack | undefined>>()
function githubPRStackExecutionScope(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): string {
return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}`
}
export function _resetMergeQueueCacheForTests(): void {
repositoryMergeMetadataCache.clear()
}
export function _resetPRStackSummaryCacheForTests(): void {
prStackSummaryCache.clear()
prStackSummaryInFlight.clear()
}
export function _getMergeQueueCacheSizeForTests(): number {
return repositoryMergeMetadataCache.size
}
function pruneRepositoryMergeMetadataCache(now = Date.now()): void {
for (const [cacheKey, cached] of repositoryMergeMetadataCache) {
if (cached.expiresAt <= now) {
repositoryMergeMetadataCache.delete(cacheKey)
}
}
while (repositoryMergeMetadataCache.size > MERGE_QUEUE_CACHE_MAX_ENTRIES) {
const oldestKey = repositoryMergeMetadataCache.keys().next().value
if (oldestKey === undefined) {
break
}
repositoryMergeMetadataCache.delete(oldestKey)
}
}
async function assertRateLimitBudget(
bucket: RateLimitBucketKind,
repository?: GitHubApiRepository | null,
executionOptions?: Pick<GhExecOptions, 'cwd' | 'wslDistro'>
): Promise<void> {
if (spendsSharedGitHubComQuota(repository, executionOptions)) {
await getRateLimit()
}
const guard = repositoryRateLimitGuard(repository, bucket, executionOptions)
if (guard.blocked) {
throw new Error(
`GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}`
)
}
}
// Why: a branch lookup prefers REST but can fall back to `gh pr list` and
// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh
// coordinator's own estimate.
const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const
/**
* Rate-limit floor for GitHub PR lookups that do not run through the PR refresh
* coordinator's queue (#11532).
*
* The coordinator guards and paces its own background refreshes, but
* `hostedReview:forBranch` polls the same lookup straight from the renderer.
* Ungated, the two paths together could spend the user's entire hourly quota —
* which is per user and shared with their own `gh` and CLI agents.
* Returns the reset time when the caller must not spend, else `null`.
*/
export async function getGitHubPRLookupRateLimitBlock(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ resetAt: number } | null> {
const executionOptions = ghRepoExecOptions(
githubRepoContext(repoPath, connectionId, localGitOptions)
)
// Why: identity resolution runs local git, which can fail for reasons that
// have nothing to do with the budget; let the lookup itself classify those.
const repository = await getOriginGitHubApiRepository(
repoPath,
connectionId,
executionOptions
).catch(() => null)
if (repository === null) {
return null
}
if (spendsSharedGitHubComQuota(repository, executionOptions)) {
// Why: the probe only warms the snapshot and is exempt from limits, so a
// failure must fail open rather than block the lookup (#7553).
await getRateLimit().catch(() => undefined)
}
// Why: retrying at the earlier reset would fail again on the bucket that has
// not reset yet, so the latest blocked reset is the only honest retry time.
const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) =>
repositoryRateLimitGuard(repository, bucket, executionOptions)
).flatMap((guard) => (guard.blocked ? [guard.resetAt] : []))
return resets.length > 0 ? { resetAt: Math.max(...resets) } : null
}
function prRefreshUpstreamError(
err: unknown
): Extract<PRRefreshOutcome, { kind: 'upstream-error' }> {
const errorType = classifyPRRefreshError(err)
const outcome: Extract<PRRefreshOutcome, { kind: 'upstream-error' }> = {
kind: 'upstream-error',
errorType,
message: safePRRefreshErrorMessage(errorType),
fetchedAt: Date.now()
}
// Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429.
if (errorType === 'rate_limited') {
const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr)
if (retryAfterMs !== null && retryAfterMs > 0) {
const retryAt = Date.now() + retryAfterMs
outcome.nextAutoRetryAt = retryAt
outcome.retryDisabledUntil = retryAt
}
}
return outcome
}
function isNoPullRequestError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return /no pull requests? found|could not find.*pull request/i.test(message)
}
/**
* 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()
}
}
function pickPushRemoteUrl(args: {
originUrl: string | null
cloneUrl: string
sshUrl: string
}): string {
const { originUrl, cloneUrl, sshUrl } = args
if (originUrl && (/^(git@|ssh:)/.test(originUrl) || originUrl.includes('ssh.github.com'))) {
return sshUrl
}
return cloneUrl
}
function sanitizeRemoteName(owner: string, repo: string): string {
const slug = `${owner}-${repo}`
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
return slug ? `pr-${slug}` : 'pr-head'
}
/**
* A fork push target plus the PR's `maintainer_can_modify` flag, kept outside
* {@link GitPushTarget} so it never leaks into the persisted push-target shape.
*/
export type PullRequestPushTarget = {
pushTarget: GitPushTarget
/** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */
maintainerCanModify?: boolean
}
// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/
// undefined keep the multi-candidate probe ordered upstream-first, matching
// resolvePrWorkItemSource list semantics.
async function resolvePullRequestLookupCandidates(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepository[]> {
if (preference === 'origin') {
const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
return origin ? [origin] : []
}
return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions))
.candidates
}
export async function getPullRequestPushTarget(
repoPath: string,
prNumber: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<PullRequestPushTarget | null> {
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(context)
const candidates = await resolvePullRequestLookupCandidates(
repoPath,
preference,
connectionId,
localGitOptions
)
if (candidates.length === 0) {
return null
}
await acquire()
try {
let prStdout = ''
let matchedRepository: GitHubApiRepository | null = null
for (const candidate of candidates) {
try {
const { stdout } = await ghExecFileAsync(
['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`],
{ ...ghOptions, ...githubHostExecOptions(candidate) }
)
prStdout = stdout
matchedRepository = candidate
break
} catch (error) {
// Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up.
if (isNotFoundGhError(error)) {
continue
}
throw error
}
}
if (!prStdout || !matchedRepository) {
return null
}
const origin = await getGitHubApiRepositoryForRemote(
repoPath,
'origin',
connectionId,
localGitOptions
)
const pr = JSON.parse(prStdout) as {
maintainer_can_modify?: boolean
head?: {
ref?: string
repo?: {
full_name?: string
clone_url?: string
ssh_url?: string
owner?: { login?: string }
name?: string
} | null
}
}
const headRepo = pr.head?.repo
const branchName = pr.head?.ref?.trim()
const owner = headRepo?.owner?.login?.trim()
const repo = headRepo?.name?.trim() ?? headRepo?.full_name?.split('/')[1]?.trim()
const cloneUrl = headRepo?.clone_url?.trim()
const sshUrl = headRepo?.ssh_url?.trim()
const maintainerCanModify =
typeof pr.maintainer_can_modify === 'boolean' ? pr.maintainer_can_modify : undefined
if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) {
return null
}
if (
origin &&
githubRepoIdentityKey(origin) ===
githubRepoIdentityKey({ owner, repo, host: matchedRepository.host })
) {
return {
pushTarget: { remoteName: 'origin', branchName },
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
}
}
let originUrl: string | null = null
try {
const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin')
originUrl = rawOriginUrl?.trim() || null
} catch {
originUrl = null
}
return {
pushTarget: {
remoteName: sanitizeRemoteName(owner, repo),
branchName,
remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl })
},
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
}
} 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()
}
}
/**
* 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()
}
}
// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC.
export type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages.
const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc'
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL.
// Requested reviewers stay in the list payload because Tasks renders that column on first paint.
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'
function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
id: `issue:${String(item.number)}`,
type: 'issue',
number: Number(item.number),
title: String(item.title ?? ''),
state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
url: String(item.html_url ?? item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
...authorFieldsFromUnknown(item),
...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {})
}
}
/**
* Derive author login + avatar_url together so GHE avatars render — the login-only
* `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784).
*/
function authorFieldsFromUnknown(
item: Record<string, unknown>
): Pick<MainWorkItem, 'author' | 'authorAvatarUrl'> {
const user = userFromUnknown(item.user ?? item.author)
if (!user) {
return { author: null }
}
return {
author: user.login,
...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {})
}
}
function extractHeadOwnerLogin(item: Record<string, unknown>): string | null {
// gh CLI `pr list --json headRepositoryOwner` shape: { login }
if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) {
const login = (item.headRepositoryOwner as { login?: unknown }).login
if (typeof login === 'string' && login.trim()) {
return login
}
}
// REST API `pull_request` shape: head.repo.owner.login
if (typeof item.head === 'object' && item.head !== null) {
const head = item.head as { repo?: unknown; user?: unknown; label?: unknown }
const repo = head.repo
if (typeof repo === 'object' && repo !== null) {
const owner = (repo as { owner?: unknown }).owner
if (typeof owner === 'object' && owner !== null) {
const login = (owner as { login?: unknown }).login
if (typeof login === 'string' && login.trim()) {
return login
}
}
}
// Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label.
const user = head.user
if (typeof user === 'object' && user !== null) {
const login = (user as { login?: unknown }).login
if (typeof login === 'string' && login.trim()) {
return login
}
}
if (typeof head.label === 'string') {
const owner = head.label.split(':', 1)[0]?.trim()
if (owner) {
return owner
}
}
}
return null
}
function userFromUnknown(
value: unknown
): { login: string; name: string | null; avatarUrl: string } | null {
if (typeof value === 'string') {
const login = value.trim()
return login ? { login, name: null, avatarUrl: '' } : null
}
if (typeof value !== 'object' || value === null) {
return null
}
const raw = value as Record<string, unknown>
const login = typeof raw.login === 'string' ? raw.login.trim() : ''
if (!login) {
return null
}
const databaseId = numberFromUnknown(raw.databaseId)
return {
login,
name: typeof raw.name === 'string' ? raw.name : null,
avatarUrl:
typeof raw.avatarUrl === 'string'
? raw.avatarUrl
: typeof raw.avatar_url === 'string'
? raw.avatar_url
: databaseId !== undefined
? `https://avatars.githubusercontent.com/u/${databaseId}?v=4`
: ''
}
}
function usersFromUnknown(
value: unknown
): { login: string; name: string | null; avatarUrl: string }[] {
if (!Array.isArray(value)) {
return []
}
const users: { login: string; name: string | null; avatarUrl: string }[] = []
for (const entry of value) {
const direct = userFromUnknown(entry)
if (direct) {
users.push(direct)
continue
}
if (typeof entry === 'object' && entry !== null) {
const raw = entry as Record<string, unknown>
const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author)
if (nested) {
users.push(nested)
}
}
}
return users
}
function latestReviewsFromUnknown(value: unknown): NonNullable<GitHubWorkItem['latestReviews']> {
if (!Array.isArray(value)) {
return []
}
const reviews: NonNullable<GitHubWorkItem['latestReviews']> = []
for (const entry of value) {
if (typeof entry !== 'object' || entry === null) {
continue
}
const raw = entry as Record<string, unknown>
const author = userFromUnknown(raw.author)
if (!author) {
continue
}
reviews.push({
login: author.login,
state: typeof raw.state === 'string' ? raw.state : null,
avatarUrl: author.avatarUrl
})
}
return reviews
}
function numberFromUnknown(value: unknown): number | undefined {
const number = typeof value === 'number' ? value : Number(value)
return Number.isFinite(number) ? number : undefined
}
function normalizePRMergeable(value: unknown): PRMergeableState | undefined {
const raw = typeof value === 'string' ? value.toUpperCase() : ''
if (raw === 'MERGEABLE' || raw === 'CONFLICTING' || raw === 'UNKNOWN') {
return raw
}
if (typeof value === 'boolean') {
return value ? 'MERGEABLE' : 'CONFLICTING'
}
return undefined
}
function normalizeReviewDecision(value: unknown): PRReviewDecision | null {
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
? value
: null
}
function isAutoMergeEnabled(value: unknown): boolean {
return typeof value === 'object' && value !== null
}
function checkRollupEntries(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value
}
if (typeof value !== 'object' || value === null) {
return []
}
const raw = value as Record<string, unknown>
const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes
return Array.isArray(nodes) ? nodes : []
}
function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] {
return summarizeProviderChecks(
checkRollupEntries(value).map((entry) => {
if (typeof entry !== 'object' || entry === null) {
return { status: '', conclusion: '' }
}
const raw = entry as Record<string, unknown>
// Why: StatusContext reports `state` and carries no `status`; CheckRun reports both.
return {
status: String(raw.status ?? ''),
conclusion: String(raw.conclusion ?? raw.state ?? '')
}
})
)
}
function mapPullRequestWorkItem(
item: Record<string, unknown>,
baseOwnerRepo: OwnerRepo | null = null
): MainWorkItem {
// Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner.
const headOwnerLogin = extractHeadOwnerLogin(item)
// Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork".
const isCrossRepository =
headOwnerLogin !== null && baseOwnerRepo !== null
? headOwnerLogin !== baseOwnerRepo.owner
: null
const state = String(item.state ?? '').toLowerCase()
const additions = numberFromUnknown(item.additions)
const deletions = numberFromUnknown(item.deletions)
const changedFiles = numberFromUnknown(
item.changedFiles ??
item.changed_files ??
(item.files as { totalCount?: unknown } | undefined)?.totalCount
)
const mergeable = normalizePRMergeable(item.mergeable)
const headSha =
typeof item.headRefOid === 'string'
? item.headRefOid
: typeof item.head === 'object' && item.head !== null
? typeof (item.head as { sha?: unknown }).sha === 'string'
? (item.head as { sha: string }).sha
: undefined
: undefined
return {
id: `pr:${String(item.number)}`,
type: 'pr',
number: Number(item.number),
title: String(item.title ?? ''),
state:
state === 'merged' || item.merged_at || item.mergedAt
? 'merged'
: state === 'closed'
? 'closed'
: item.isDraft || item.draft
? 'draft'
: 'open',
url: String(item.html_url ?? item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
...authorFieldsFromUnknown(item),
branchName:
typeof item.head === 'object' && item.head !== null && 'ref' in item.head
? String((item.head as { ref?: unknown }).ref ?? '')
: String(item.headRefName ?? ''),
baseRefName:
typeof item.base === 'object' && item.base !== null && 'ref' in item.base
? String((item.base as { ref?: unknown }).ref ?? '')
: String(item.baseRefName ?? ''),
...(headSha ? { headSha } : {}),
...(baseOwnerRepo
? {
prRepo: {
owner: baseOwnerRepo.owner,
repo: baseOwnerRepo.repo,
...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {})
}
}
: {}),
...(additions !== undefined ? { additions } : {}),
...(deletions !== undefined ? { deletions } : {}),
...(changedFiles !== undefined ? { changedFiles } : {}),
...('reviewDecision' in item
? { reviewDecision: normalizeReviewDecision(item.reviewDecision) }
: {}),
...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) }
: {}),
...(item.latestReviews !== undefined
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
: {}),
...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}),
...(item.statusCheckRollup !== undefined
? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) }
: {}),
...(mergeable ? { mergeable } : {}),
...('autoMergeRequest' in item
? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) }
: {}),
...('mergeStateStatus' in item
? {
mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null
}
: {}),
...(typeof item.maintainerCanModify === 'boolean'
? { maintainerCanModify: item.maintainerCanModify }
: {}),
...(isCrossRepository !== null ? { isCrossRepository } : {})
}
}
async function hydrateWorkItemRepositoryMergeMetadata(
items: MainWorkItem[],
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<MainWorkItem[]> {
const hasPullRequest = items.some((item) => item.type === 'pr')
if (!ownerRepo || !hasPullRequest) {
return items
}
// Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out.
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, undefined, ghOptions)
if (!mergeMetadata.mergeMethodSettings && mergeMetadata.autoMergeAllowed === null) {
return items
}
return items.map((item) =>
item.type === 'pr'
? {
...item,
...(mergeMetadata.autoMergeAllowed !== null
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
: {}),
...(mergeMetadata.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
}
: item
)
}
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.
const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
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 {}
}
}
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>)
}
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
}
type WorkItemListRequest = {
args: string[]
offset: number
}
function normalizeWorkItemPage(page: number | undefined): number {
return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
}
function buildWorkItemListRequest(args: {
kind: 'issue' | 'pr'
ownerRepo: OwnerRepo
limit: number
query: ParsedTaskQuery
page: number
}): WorkItemListRequest {
const { kind, ownerRepo, limit, query, page } = args
const searchParts: string[] = []
if (kind === 'issue') {
searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`)
}
searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr')
if (query.state === 'open') {
searchParts.push('is:open')
} else if (query.state === 'closed') {
searchParts.push('is:closed')
if (kind === 'pr') {
searchParts.push('-is:merged')
}
} else if (query.state === 'merged') {
searchParts.push('is:merged')
}
if (kind === 'pr' && query.draft) {
searchParts.push('draft:true')
}
if (query.assignee) {
searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
}
if (query.author) {
searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`)
}
if (query.labels.length > 0) {
for (const label of query.labels) {
searchParts.push(`label:${quoteGitHubSearchValue(label)}`)
}
}
if (kind === 'pr' && query.reviewRequested) {
searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
}
if (kind === 'pr' && query.reviewedBy) {
searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
}
if (query.freeText) {
searchParts.push(query.freeText)
}
if (kind === 'issue') {
return {
args: [
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`,
'--jq',
'.items'
],
offset: 0
}
}
// Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort.
searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER)
const out = [
'pr',
'list',
'--limit',
String(Math.min(page * limit, 1000)),
'--state',
'all',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
]
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
out.push('--search', searchParts.join(' '))
return { args: out, offset: (page - 1) * limit }
}
// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485).
type PartialWorkItemsResult = {
items: MainWorkItem[]
issuesError?: ClassifiedError
prsError?: ClassifiedError
}
function assertSshRepoHasResolvedGitHubSource(args: {
connectionId?: string | null
issueOwnerRepo: OwnerRepo | null
prOwnerRepo: OwnerRepo | null
}): void {
if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) {
return
}
// Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state.
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
type ResolvedPrWorkItemSource = {
source: OwnerRepo | null
originCandidate: OwnerRepo | null
upstreamCandidate: OwnerRepo | null
}
async function resolvePrWorkItemSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ResolvedPrWorkItemSource> {
const [originCandidate, upstreamCandidate] = await Promise.all([
getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions)
])
// Why: fork-contribution PRs live on the upstream repo (the fork's own PR
// list is almost always empty), so 'auto' resolves upstream-first exactly
// like the issue side. Only an explicit 'origin' pick pins PRs to the fork.
const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate)
return { source, originCandidate, upstreamCandidate }
}
async function listRecentWorkItems(
repoPath: string,
issueOwnerRepo: OwnerRepo | null,
prOwnerRepo: OwnerRepo | null,
limit: number,
page: number,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<PartialWorkItemsResult> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
const recentQuery = parseTaskQuery('is:open')
const issueRequest = issueOwnerRepo
? buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query: recentQuery,
page
})
: null
const prRequest = prOwnerRepo
? buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query: recentQuery,
page
})
: null
if (noCache && issueRequest) {
issueRequest.args.splice(1, 2)
}
// Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660).
// Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner).
const [issuesSettled, prsSettled] = await Promise.allSettled([
issueRequest && issueOwnerRepo
? ghExecFileAsync(issueRequest.args, {
...ghOptions,
...githubHostExecOptions(issueOwnerRepo)
})
: Promise.resolve({ stdout: '[]' }),
prRequest && prOwnerRepo
? ghExecFileAsync(prRequest.args, {
...ghOptions,
...githubHostExecOptions(prOwnerRepo)
})
: Promise.resolve({ stdout: '[]' })
])
let issues: MainWorkItem[] = []
let issuesError: ClassifiedError | undefined
if (issuesSettled.status === 'fulfilled') {
issues = (JSON.parse(issuesSettled.value.stdout) as Record<string, unknown>[])
// Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out.
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
} else {
const stderr =
issuesSettled.reason instanceof Error
? issuesSettled.reason.message
: String(issuesSettled.reason)
issuesError = classifyListIssuesError(stderr)
}
let prs: MainWorkItem[] = []
if (prsSettled.status === 'fulfilled') {
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[])
.slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, {
...ghOptions,
...githubHostExecOptions(prOwnerRepo)
})
} else {
// Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076).
// Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop.
if (issuesError) {
console.warn(
'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:',
issuesError.type,
issuesError.message
)
}
throw prsSettled.reason
}
return {
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit),
issuesError
}
}
async function listQueriedWorkItems(
repoPath: string,
issueOwnerRepo: OwnerRepo | null,
prOwnerRepo: OwnerRepo | null,
query: ParsedTaskQuery,
limit: number,
page?: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PartialWorkItemsResult> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
const hasPrOnlyFilter =
query.state === 'merged' ||
query.draft ||
query.reviewRequested !== null ||
query.reviewedBy !== null
const issueScope = query.scope !== 'pr' && !hasPrOnlyFilter
const prScope = query.scope !== 'issue'
let successfulRequestCount = 0
let nonAvailabilityFailureCount = 0
let availabilityError: unknown
let prsError: ClassifiedError | undefined
// Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6).
const issueFetch = (async (): Promise<PartialWorkItemsResult> => {
if (!issueScope) {
return { items: [] }
}
if (!issueOwnerRepo) {
return { items: [] }
}
const request = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query,
page: page ?? 1
})
try {
const { stdout } = await ghExecFileAsync(request.args, {
...ghOptions,
...githubHostExecOptions(issueOwnerRepo)
})
const items = (JSON.parse(stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
successfulRequestCount += 1
return { items }
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
if (classifyGitHubUnavailable(stderr)) {
availabilityError ??= err
} else {
nonAvailabilityFailureCount += 1
}
return { items: [], issuesError: classifyListIssuesError(stderr) }
}
})()
const prFetch = (async (): Promise<MainWorkItem[]> => {
if (!prScope) {
return []
}
if (!prOwnerRepo) {
return []
}
const request = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query,
page: page ?? 1
})
try {
const { stdout } = await ghExecFileAsync(request.args, {
...ghOptions,
...githubHostExecOptions(prOwnerRepo)
})
const mapped = (JSON.parse(stdout) as Record<string, unknown>[])
.slice(request.offset, request.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, {
...ghOptions,
...githubHostExecOptions(prOwnerRepo)
})
successfulRequestCount += 1
if (query.state === 'closed') {
return hydrated.filter((item) => item.state !== 'merged')
}
return hydrated
} catch (err) {
console.warn('listQueriedWorkItems PRs partial failure:', err)
const stderr = err instanceof Error ? err.message : String(err)
prsError = classifyListPrsError(stderr)
if (classifyGitHubUnavailable(stderr)) {
availabilityError ??= err
} else {
nonAvailabilityFailureCount += 1
}
return []
}
})()
const [issueResult, prItems] = await Promise.all([issueFetch, prFetch])
if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) {
// Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data.
throw availabilityError
}
return {
items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit),
issuesError: issueResult.issuesError,
prsError
}
}
export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string,
page?: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListWorkItemsResult<MainWorkItem>> {
const trimmedQuery = query?.trim() ?? ''
const requestedPage = normalizeWorkItemPage(page)
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return {
items: [],
sources: {
issues: null,
prs: null,
originCandidate: null,
upstreamCandidate: null
}
}
}
const [issueResolved, prResolved] = await Promise.all([
resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
])
const issueOwnerRepo = issueResolved.source
const prOwnerRepo = prResolved.source
await acquire()
try {
// Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures.
const partial = !trimmedQuery
? await listRecentWorkItems(
repoPath,
issueOwnerRepo,
prOwnerRepo,
limit,
requestedPage,
connectionId,
noCache,
localGitOptions
)
: await listQueriedWorkItems(
repoPath,
issueOwnerRepo,
prOwnerRepo,
parseTaskQuery(trimmedQuery),
limit,
requestedPage,
connectionId,
localGitOptions
)
const errors =
partial.issuesError || partial.prsError
? {
...(partial.issuesError ? { issues: partial.issuesError } : {}),
...(partial.prsError ? { prs: partial.prsError } : {})
}
: undefined
return {
items: partial.items,
sources: {
issues: issueOwnerRepo,
prs: prOwnerRepo,
originCandidate: prResolved.originCandidate,
upstreamCandidate: prResolved.upstreamCandidate
},
...(errors ? { errors } : {}),
...(issueResolved.fellBack ? { issueSourceFellBack: true } : {})
}
} finally {
release()
}
}
function buildSearchQueryString(
ownerRepo: { owner: string; repo: string },
query: ParsedTaskQuery
): string {
const parts: string[] = [`repo:${ownerRepo.owner}/${ownerRepo.repo}`]
if (query.scope === 'pr') {
parts.push('is:pull-request')
} else if (query.scope === 'issue') {
parts.push('is:issue')
}
if (query.state === 'open') {
parts.push('is:open')
} else if (query.state === 'closed') {
// Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge.
parts.push('is:closed')
if (query.scope !== 'issue') {
parts.push('-is:merged')
}
} else if (query.state === 'merged') {
parts.push('is:merged')
}
if (query.draft) {
parts.push('draft:true')
}
if (query.assignee) {
parts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
}
if (query.author) {
parts.push(`author:${quoteGitHubSearchValue(query.author)}`)
}
if (query.reviewRequested) {
parts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
}
if (query.reviewedBy) {
parts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
}
for (const label of query.labels) {
parts.push(`label:${quoteGitHubSearchValue(label)}`)
}
if (query.freeText) {
parts.push(query.freeText)
}
return parts.join(' ')
}
function quoteGitHubSearchValue(value: string): string {
return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
}
async function countWorkItemsForQuery(
repoPath: string,
ownerRepo: OwnerRepo,
query: ParsedTaskQuery,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<number> {
const searchQ = buildSearchQueryString(ownerRepo, query)
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(ownerRepo)
}
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`,
'--jq',
'.total_count'
],
ghOptions
)
// Why: over-counting cache hits is the safe direction — the next probe corrects the estimate.
noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions)
return Number.parseInt(stdout.trim(), 10) || 0
}
function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean {
// Why: casing does not distinguish GitHub repos, but the same slug on different hosts does.
return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right))
}
function defaultOpenWorkItemQuery(): ParsedTaskQuery {
return {
scope: 'all',
state: 'open',
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
}
// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total.
export async function countWorkItems(
repoPath: string,
query?: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<number> {
const trimmedQuery = query?.trim() ?? ''
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return 0
}
const [issueResolved, prResolved] = await Promise.all([
resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
])
const issueOwnerRepo = issueResolved.source
const prOwnerRepo = prResolved.source
const ownerRepo = prOwnerRepo ?? issueOwnerRepo
if (!ownerRepo) {
return 0
}
const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null
const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery()
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(ownerRepo)
}
// Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached).
if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) {
await getRateLimit()
}
if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) {
return 0
}
await acquire()
try {
if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) {
return await countWorkItemsForQuery(
repoPath,
ownerRepo,
effectiveQuery,
connectionId,
localGitOptions
)
}
const counts: Promise<number>[] = []
// Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call.
const hasPrOnlyFilter =
effectiveQuery.draft ||
effectiveQuery.reviewRequested !== null ||
effectiveQuery.reviewedBy !== null
if (
effectiveQuery.scope !== 'pr' &&
effectiveQuery.state !== 'merged' &&
!hasPrOnlyFilter &&
issueOwnerRepo
) {
counts.push(
countWorkItemsForQuery(
repoPath,
issueOwnerRepo,
{ ...effectiveQuery, scope: 'issue' },
connectionId,
localGitOptions
)
)
}
if (effectiveQuery.scope !== 'issue' && prOwnerRepo) {
counts.push(
countWorkItemsForQuery(
repoPath,
prOwnerRepo,
{ ...effectiveQuery, scope: 'pr' },
connectionId,
localGitOptions
)
)
}
// Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves.
const results = await Promise.allSettled(counts)
let total = 0
for (const r of results) {
if (r.status === 'fulfilled') {
total += r.value
} else {
console.warn('countWorkItems partial failure:', r.reason)
}
}
return total
} catch (err) {
console.warn('countWorkItems failed:', err)
return 0
} finally {
release()
}
}
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()
}
}
function classifyCreatePRError(error: unknown): CreateHostedReviewResult {
const { stderr, stdout } = extractExecError(error)
const message = `${stderr}\n${stdout}`.trim()
if (message) {
console.warn('createGitHubPullRequest failed:', message)
}
const lower = message.toLowerCase()
if (
lower.includes('not logged') ||
lower.includes('not authenticated') ||
lower.includes('authentication') ||
lower.includes('gh auth login') ||
lower.includes('http 401')
) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.'
}
}
if (lower.includes('already exists') || lower.includes('a pull request already exists')) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.'
}
}
if (lower.includes('timed out') || lower.includes('timeout')) {
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
}
if (lower.includes('validation failed') || lower.includes('http 422')) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: GitHub rejected the pull request. Check the base branch and branch state, then try again.'
}
}
return {
ok: false,
code: 'unknown',
error: 'Create PR failed: GitHub could not create the pull request. Try again in a moment.'
}
}
function parseCreatePRPayload(stdout: string): { number: number; url: string } | null {
const trimmed = stdout.trim()
if (!trimmed) {
return null
}
try {
const parsed = JSON.parse(trimmed) as { number?: unknown; url?: unknown }
const number = Number(parsed.number)
const url = typeof parsed.url === 'string' ? parsed.url.trim() : ''
if (Number.isInteger(number) && number > 0 && url) {
return { number, url }
}
} catch {
// Fall through to URL parsing for older gh versions without --json support.
}
// Why: match any host (not just github.com) so a GHES PR URL still parses (#8312).
const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/)
if (!urlMatch) {
return null
}
return { number: Number(urlMatch[1]), url: urlMatch[0] }
}
async function findOpenPRByHeadBase(args: {
repoPath: string
repo: GitHubApiRepository
head: string
base: string
connectionId?: string | null
options?: HostedReviewExecutionOptions
}): Promise<{ number: number; url: string } | null> {
const context = githubRepoContext(args.repoPath, args.connectionId)
const { stdout } = await ghExecFileAsync(
[
'pr',
'list',
'--repo',
`${args.repo.owner}/${args.repo.repo}`,
'--head',
args.head,
'--base',
args.base,
'--state',
'open',
'--limit',
'2',
'--json',
'number,url'
],
{
...ghRepoExecOptions(context),
...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)),
...githubHostExecOptions(args.repo)
}
)
const list = JSON.parse(stdout) as { number?: number; url?: string }[]
if (list.length !== 1 || !list[0]?.number || !list[0]?.url) {
return null
}
return { number: list[0].number, url: list[0].url }
}
async function readPullRequestTemplate(
repoPath: string,
connectionId?: string | null
): Promise<string> {
const relativeCandidates = [
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'pull_request_template.md',
'PULL_REQUEST_TEMPLATE.md',
'docs/pull_request_template.md',
'docs/PULL_REQUEST_TEMPLATE.md'
]
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
if (connectionId && !remoteProvider) {
return ''
}
for (const relativeCandidate of relativeCandidates) {
try {
if (remoteProvider) {
const result = await remoteProvider.readFile(
joinWorktreeRelativePath(repoPath, relativeCandidate)
)
if (result.isBinary) {
continue
}
return result.content
}
return await readFile(join(repoPath, relativeCandidate), 'utf8')
} catch {
// Try the next conventional PR template path.
}
}
return ''
}
export async function createGitHubPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'github') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
// Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312).
const ownerRepo = await getOriginGitHubApiRepository(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
if (!ownerRepo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires a GitHub remote.'
}
}
// The runner host-qualifies --repo from options.host for GHES (#8312).
const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}`
const base = normalizeHostedReviewBaseRef(input.base)
const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined
const title = input.title.trim()
if (!base || !title) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: base branch and title are required.'
}
}
if (head && head.toLowerCase() === base.toLowerCase()) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: choose a different base branch before creating a pull request.'
}
}
const tempDir = await mkdtemp(join(tmpdir(), 'orca-pr-body-'))
await acquire()
const bodyPath = join(tempDir, 'body.md')
try {
const body =
input.useTemplate && !input.body?.trim()
? await readPullRequestTemplate(repoPath, connectionId)
: (input.body ?? '')
await writeFile(bodyPath, body, 'utf8')
const createArgs = [
'pr',
'create',
'--repo',
repoArg,
'--base',
base,
'--title',
title,
'--body-file',
bodyPath
]
if (head) {
createArgs.push('--head', head)
}
if (input.draft) {
createArgs.push('--draft')
}
try {
const context = githubRepoContext(repoPath, connectionId)
const { stdout } = await ghExecFileAsync(createArgs, {
...ghRepoExecOptions(context),
...(connectionId ? {} : getHostedReviewLocalGitOptions(options)),
...githubHostExecOptions(ownerRepo),
timeout: 60_000,
idempotent: false
})
const created = parseCreatePRPayload(stdout)
if (created) {
return { ok: true, ...created }
}
const found = head
? await findOpenPRByHeadBase({
repoPath,
repo: ownerRepo,
head,
base,
connectionId,
options
}).catch(() => null)
: null
if (found) {
return { ok: true, ...found }
}
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
} catch (error) {
const classified = classifyCreatePRError(error)
if (
!classified.ok &&
(classified.code === 'already_exists' || classified.code === 'unknown_completion') &&
head
) {
const existing = await findOpenPRByHeadBase({
repoPath,
repo: ownerRepo,
head,
base,
connectionId,
options
}).catch(() => null)
if (existing) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.',
existingReview: existing
}
}
}
return classified
}
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
release()
}
}
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()
}
}
type PullRequestLookupData = {
number: number
title: string
state: string
url: string
statusCheckRollup: unknown[]
updatedAt: string
isDraft?: boolean
mergeable: string
reviewDecision?: PRReviewDecision | null
autoMergeRequest?: unknown
autoMergeEnabled?: boolean
autoMergeAllowed?: boolean | null
mergeQueueRequired?: boolean | null
mergeMethodSettings?: GitHubPRMergeMethodSettings
mergeStateStatus?: string | null
baseRefName?: string
headRefName?: string
baseRefOid?: string
headRefOid?: string
stack?: GitHubPRStack
stackMetadataChecked?: boolean
}
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 }
stack?: {
number?: number
position?: number
size?: number
base?: { ref?: string; sha?: string }
} | null
}
const PR_LOOKUP_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_BRANCH_LIST_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
const GITHUB_AUTO_MERGE_METHODS: Record<GitHubPRMergeMethod, 'MERGE' | 'SQUASH' | 'REBASE'> = {
merge: 'MERGE',
squash: 'SQUASH',
rebase: 'REBASE'
}
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
acceptMergedFallbackPR?: boolean
// Why: compare merged implicit PRs against the worktree HEAD, not main repo HEAD, without a worktree-scoped git call.
currentHeadOid?: string | null
}
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 derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableState {
const mergeable = normalizePRMergeable(data.mergeable)
if (mergeable === 'CONFLICTING' || data.mergeStateStatus === 'DIRTY') {
return 'CONFLICTING'
}
return mergeable ?? 'UNKNOWN'
}
function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
const stack =
typeof pr.stack?.number === 'number' &&
typeof pr.stack.position === 'number' &&
typeof pr.stack.size === 'number' &&
typeof pr.stack.base?.ref === 'string'
? {
number: pr.stack.number,
position: pr.stack.position,
size: pr.stack.size,
baseRefName: pr.stack.base.ref,
...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {})
}
: undefined
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,
stackMetadataChecked: true,
...(stack ? { stack } : {})
}
}
function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean {
// Why: a merged PR without an explicit link is just a historical branch match, not implicit review context.
return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged'
}
async function getCurrentHeadOid(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<string | null> {
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
? await provider.exec(['rev-parse', 'HEAD'], repoPath)
: await gitExecFileAsync(['rev-parse', 'HEAD'], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return result.stdout.trim() || null
} catch {
return null
}
}
function shouldHideMergedImplicitPR(
data: PullRequestLookupData | null,
linkedPRNumber: number | null | undefined,
currentHeadOid: string | null
): boolean {
if (!data || !isMergedImplicitPR(data, linkedPRNumber)) {
return false
}
// Why: keep hiding historical merged branch matches, but preserve the merged PR for the exact commit currently checked out.
return !currentHeadOid || data.headRefOid !== currentHeadOid
}
function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData {
return {
...data,
reviewDecision:
data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined,
autoMergeEnabled:
data.autoMergeEnabled ??
('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined)
}
}
function cacheRepositoryMergeMetadata(
cacheKey: string,
value: GitHubRepositoryMergeMetadata,
ttlMs: number
): void {
const now = Date.now()
pruneRepositoryMergeMetadataCache(now)
// Why: merge metadata is keyed by user-controlled branch names; keep the cache bounded across many short-lived branches.
repositoryMergeMetadataCache.delete(cacheKey)
repositoryMergeMetadataCache.set(cacheKey, {
value,
expiresAt: now + ttlMs
})
pruneRepositoryMergeMetadataCache(now)
}
async function detectRepositoryMergeMetadata(
ownerRepo: GitHubApiRepository,
branchName: string | undefined,
ghOptions: GhExecOptions,
executionScope = 'default'
): Promise<GitHubRepositoryMergeMetadata> {
const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}`
pruneRepositoryMergeMetadataCache()
const cached = repositoryMergeMetadataCache.get(cacheKey)
if (cached) {
return cached.value
}
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
if (guard.blocked) {
return { mergeQueueRequired: null, autoMergeAllowed: null }
}
const query = branchName
? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) {
repository(owner: $owner, name: $repo) {
viewerDefaultMergeMethod
mergeCommitAllowed
rebaseMergeAllowed
squashMergeAllowed
autoMergeAllowed
mergeQueue(branch: $branch) { id }
ref(qualifiedName: $qualified) {
rules(first: 50) { nodes { type } }
}
}
}`
: `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
viewerDefaultMergeMethod
mergeCommitAllowed
rebaseMergeAllowed
squashMergeAllowed
autoMergeAllowed
}
}`
try {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
const args = [
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`
]
if (branchName) {
args.push('-f', `branch=${branchName}`)
args.push('-f', `qualified=refs/heads/${branchName}`)
}
const { stdout } = await ghExecFileAsync(args, {
...ghOptions,
...githubHostExecOptions(ownerRepo)
})
const parsed = JSON.parse(stdout) as {
data?: {
repository?: {
viewerDefaultMergeMethod?: unknown
mergeCommitAllowed?: unknown
rebaseMergeAllowed?: unknown
squashMergeAllowed?: unknown
autoMergeAllowed?: unknown
mergeQueue?: { id?: unknown } | null
ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null
} | null
}
}
const repository = parsed.data?.repository
const mergeMethodSettings = repository
? normalizeGitHubPRMergeMethodSettings({
defaultMethod: repository.viewerDefaultMergeMethod,
mergeCommitAllowed: repository.mergeCommitAllowed,
rebaseMergeAllowed: repository.rebaseMergeAllowed,
squashMergeAllowed: repository.squashMergeAllowed
})
: undefined
const value: GitHubRepositoryMergeMetadata = {
mergeQueueRequired: branchName
? Boolean(repository?.mergeQueue) ||
Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE'))
: null,
autoMergeAllowed:
typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null,
...(mergeMethodSettings ? { mergeMethodSettings } : {})
}
cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS)
return value
} catch {
// Why: cache a conservative result for failed merge-queue probes so we don't retry GraphQL on every poll while GitHub/network is unhappy.
const value: GitHubRepositoryMergeMetadata = {
mergeQueueRequired: null,
autoMergeAllowed: null
}
cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS)
return value
}
}
async function hydratePullRequestLookupData(
ownerRepo: OwnerRepo,
data: PullRequestLookupData,
ghOptions: GhExecOptions,
executionScope: string
): Promise<PullRequestLookupData> {
const normalized = normalizePullRequestLookupData(data)
const hasRichMergeFields =
'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
const mergeMetadata = hasRichMergeFields
? await detectRepositoryMergeMetadata(
ownerRepo,
normalized.stack?.baseRefName ?? normalized.baseRefName,
ghOptions,
executionScope
)
: undefined
return {
...normalized,
...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}),
...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}),
...(mergeMetadata?.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
}
}
async function hydrateBranchLookupWithExactPR(
ownerRepo: OwnerRepo,
branchData: PullRequestLookupData | null,
ghOptions: GhExecOptions,
executionScope: string
): Promise<PullRequestLookupData | null> {
if (!branchData) {
return null
}
try {
return (
(await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ??
branchData
)
} catch {
return branchData
}
}
async function getRestPRForBranch(
prRepo: GitHubApiRepository,
headOwner: string,
branchName: string,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
const head = encodeURIComponent(`${headOwner}:${branchName}`)
const { stdout } = await ghExecFileAsync(
['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`],
{ ...ghOptions, ...githubHostExecOptions(prRepo) }
)
const list = JSON.parse(stdout) as RestPullRequest[]
const pr = list[0]
return pr ? mapRestPullRequest(pr) : null
}
async function getFallbackPRListForBranch(
prRepo: GitHubApiRepository,
branchName: string,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
const { stdout } = await ghExecFileAsync(
[
'pr',
'list',
'--repo',
`${prRepo.owner}/${prRepo.repo}`,
'--head',
branchName,
'--state',
'all',
'--limit',
'1',
'--json',
PR_BRANCH_LIST_JSON_FIELDS
],
{ ...ghOptions, ...githubHostExecOptions(prRepo) }
)
const list = JSON.parse(stdout) as PullRequestLookupData[]
return list[0] ?? null
}
type TrackedUpstreamBranch = {
remoteName: string
branchName: string
}
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512
type TrackedUpstreamSnapshotCacheEntry = {
expiresAt: number
gitConfigSignature?: string
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}
type TrackedUpstreamSnapshotProbeResult = {
cacheable: boolean
gitConfigSignature?: string
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}
const trackedUpstreamSnapshotCache = new Map<string, TrackedUpstreamSnapshotCacheEntry>()
const trackedUpstreamSnapshotInFlight = new Map<
string,
Promise<TrackedUpstreamSnapshotProbeResult>
>()
const trackedUpstreamSnapshotGenerations = new Map<string, symbol>()
function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol {
const generation = Symbol()
trackedUpstreamSnapshotGenerations.set(cacheKey, generation)
return generation
}
function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void {
// Why: generations only guard an active probe; retaining completed keys leaks worktree/runtime identities past the snapshot TTL.
if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) {
trackedUpstreamSnapshotGenerations.delete(cacheKey)
}
}
function pruneTrackedUpstreamSnapshotCache(now: number): void {
for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) {
if (cached.expiresAt <= now) {
trackedUpstreamSnapshotCache.delete(cacheKey)
}
}
// Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound.
while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) {
const oldestKey = trackedUpstreamSnapshotCache.keys().next().value
if (oldestKey === undefined) {
break
}
trackedUpstreamSnapshotCache.delete(oldestKey)
}
}
export function _getTrackedUpstreamBranchCacheSizesForTests(): {
snapshots: number
inFlight: number
generations: number
} {
return {
snapshots: trackedUpstreamSnapshotCache.size,
inFlight: trackedUpstreamSnapshotInFlight.size,
generations: trackedUpstreamSnapshotGenerations.size
}
}
export function __resetTrackedUpstreamBranchCacheForTests(): void {
trackedUpstreamSnapshotCache.clear()
trackedUpstreamSnapshotInFlight.clear()
trackedUpstreamSnapshotGenerations.clear()
}
function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null {
const parsed = splitRemoteBranchName(upstreamRef.trim())
if (!parsed) {
return null
}
return parsed
}
function shouldRetryTrackedUpstreamBranch(
upstreamBranch: TrackedUpstreamBranch,
branchName: string,
upstreamHeadRepo: OwnerRepo,
headRepo: OwnerRepo | null
): boolean {
if (upstreamBranch.branchName !== branchName) {
return true
}
if (!headRepo) {
return true
}
return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo)
}
async function getTrackedUpstreamBranch(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamBranch | null> {
const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions)
const now = Date.now()
const cached = trackedUpstreamSnapshotCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch(
cached,
repoPath,
connectionId,
localGitOptions
)
if (
configSignatureMatches &&
cached.upstreamsByBranchName.has(branchName) &&
canUseCachedTrackedUpstreamBranch(cached, branchName)
) {
return cached.upstreamsByBranchName.get(branchName) ?? null
}
trackedUpstreamSnapshotCache.delete(cacheKey)
}
if (cached) {
trackedUpstreamSnapshotCache.delete(cacheKey)
}
const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
if (inFlight) {
const result = await inFlight
if (result.upstreamsByBranchName.has(branchName)) {
return result.upstreamsByBranchName.get(branchName) ?? null
}
// Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null.
const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
if (retryInFlight) {
const retryResult = await retryInFlight
return retryResult.upstreamsByBranchName.get(branchName) ?? null
}
}
// Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch.
const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey)
const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions)
trackedUpstreamSnapshotInFlight.set(cacheKey, probe)
try {
const result = await probe
if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) {
trackedUpstreamSnapshotCache.set(cacheKey, {
...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}),
upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName),
expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS
})
pruneTrackedUpstreamSnapshotCache(Date.now())
}
if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) {
const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey)
if (fresherCached?.upstreamsByBranchName.has(branchName)) {
return fresherCached.upstreamsByBranchName.get(branchName) ?? null
}
}
return result.upstreamsByBranchName.get(branchName) ?? null
} finally {
if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) {
trackedUpstreamSnapshotInFlight.delete(cacheKey)
}
finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration)
}
}
async function probeTrackedUpstreamSnapshot(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamSnapshotProbeResult> {
const startingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches(
repoPath,
connectionId,
localGitOptions
)
const endingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro
const configSignatureChanged =
isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature
const gitConfigSignature =
startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined
return {
// Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick.
cacheable: !configSignatureChanged && !probeFailed,
probeFailed,
...(gitConfigSignature ? { gitConfigSignature } : {}),
upstreamsByBranchName
}
}
function getCacheableTrackedUpstreamSnapshot(
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
): Map<string, TrackedUpstreamBranch | null> {
// Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan.
return upstreamsByBranchName
}
function canUseCachedTrackedUpstreamBranch(
cached: TrackedUpstreamSnapshotCacheEntry,
branchName: string
): boolean {
return cached.upstreamsByBranchName.has(branchName)
}
async function doesTrackedUpstreamCacheConfigSignatureMatch(
cached: TrackedUpstreamSnapshotCacheEntry,
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
if (!cached.gitConfigSignature) {
return true
}
const currentSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
return currentSignature === cached.gitConfigSignature
}
function getTrackedUpstreamBranchCacheKey(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return [runtimeKey, repoPath].join('\0')
}
async function probeTrackedUpstreamBranches(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<{
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}> {
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
? await provider.exec(args, repoPath)
: await gitExecFileAsync(args, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return {
probeFailed: false,
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
}
} catch {
return { probeFailed: true, upstreamsByBranchName: new Map() }
}
}
function parseTrackedUpstreamBranches(stdout: string): Map<string, TrackedUpstreamBranch | null> {
const upstreamsByBranchName = new Map<string, TrackedUpstreamBranch | null>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const [branchName, upstreamRef] = line.split('\0')
const localBranchName = branchName?.replace(/^refs\/heads\//, '')
if (!localBranchName) {
continue
}
upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? ''))
}
return upstreamsByBranchName
}
function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null {
const remoteRefPrefix = 'refs/remotes/'
const normalizedRef = upstreamRef.trim()
if (normalizedRef.startsWith(remoteRefPrefix)) {
return parseTrackedUpstreamBranch(normalizedRef.slice(remoteRefPrefix.length))
}
if (normalizedRef.startsWith('refs/heads/')) {
return null
}
return parseTrackedUpstreamBranch(normalizedRef)
}
async function lookupPRByBranchName(args: {
candidates: OwnerRepo[]
headRepo: OwnerRepo | null
branchName: string
ghOptions: GhExecOptions
executionScope: string
}): Promise<{
data: PullRequestLookupData | null
dataRepo: OwnerRepo | null
pendingError?: unknown
}> {
if (args.candidates.length > 0) {
let pendingError: unknown
let hasPendingError = false
for (const candidate of args.candidates) {
try {
const branchData = args.headRepo
? await getRestPRForBranch(
candidate,
args.headRepo.owner,
args.branchName,
args.ghOptions
)
: await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions)
// Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state.
const data = await hydrateBranchLookupWithExactPR(
candidate,
branchData,
args.ghOptions,
args.executionScope
)
if (data) {
return { data, dataRepo: candidate }
}
} catch (err) {
if (args.headRepo) {
throw err
}
if (!hasPendingError) {
pendingError = err
hasPendingError = true
}
try {
const branchData = await getRestPRForBranch(
candidate,
candidate.owner,
args.branchName,
args.ghOptions
)
const data = await hydrateBranchLookupWithExactPR(
candidate,
branchData,
args.ghOptions,
args.executionScope
)
if (data) {
return { data, dataRepo: candidate }
}
} catch (retryErr) {
if (!hasPendingError) {
pendingError = retryErr
hasPendingError = true
}
}
}
}
// Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error.
return hasPendingError
? { data: null, dataRepo: null, pendingError }
: { data: null, dataRepo: null }
}
try {
const { stdout } = await ghExecFileAsync(
['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS],
args.ghOptions
)
return {
data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
dataRepo: null
}
} catch (err) {
if (isNoPullRequestError(err)) {
return { data: null, dataRepo: null }
}
throw err
}
}
async function getRestPRByNumber(
ownerRepo: GitHubApiRepository,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
{ ...ghOptions, ...githubHostExecOptions(ownerRepo) }
)
return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest)
}
function prunePRStackSummaryCache(now = Date.now()): void {
for (const [key, cached] of prStackSummaryCache) {
if (cached.expiresAt <= now) {
prStackSummaryCache.delete(key)
}
}
while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) {
const oldestKey = prStackSummaryCache.keys().next().value
if (oldestKey === undefined) {
return
}
prStackSummaryCache.delete(oldestKey)
}
}
async function getCachedGitHubPRStackSummary(
ownerRepo: GitHubApiRepository,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>,
executionScope: string
): Promise<GitHubPRStack | undefined> {
const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}`
const now = Date.now()
prunePRStackSummaryCache(now)
const cached = prStackSummaryCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.value
}
const existing = prStackSummaryInFlight.get(key)
if (existing) {
return existing
}
const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr?.stack)
prStackSummaryInFlight.set(key, request)
try {
const value = await request
prStackSummaryCache.delete(key)
prStackSummaryCache.set(key, {
value,
expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
})
prunePRStackSummaryCache()
return value
} catch (err) {
// Why: avoid repeating a failed REST probe on every review poll.
prStackSummaryCache.delete(key)
prStackSummaryCache.set(key, {
value: undefined,
expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
})
prunePRStackSummaryCache()
throw err
} finally {
if (prStackSummaryInFlight.get(key) === request) {
prStackSummaryInFlight.delete(key)
}
}
}
async function getPRByNumber(
ownerRepo: GitHubApiRepository,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>,
executionScope: string,
knownPullRequestData?: PullRequestLookupData | null
): Promise<PullRequestLookupData | null> {
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
PR_LOOKUP_JSON_FIELDS
],
{ ...ghOptions, ...githubHostExecOptions(ownerRepo) }
)
const exactData = JSON.parse(stdout) as PullRequestLookupData
return hydratePullRequestLookupData(
ownerRepo,
{
...knownPullRequestData,
...exactData,
...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {})
},
ghOptions,
executionScope
)
} catch (err) {
// Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
if (isNotFoundGhError(err)) {
return null
}
try {
const restData =
knownPullRequestData === undefined
? await getRestPRByNumber(ownerRepo, number, ghOptions)
: knownPullRequestData
return restData
? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope)
: null
} catch (restErr) {
if (isNotFoundGhError(restErr)) {
return null
}
if (!shouldStopAfterExactLookupError(restErr)) {
return null
}
throw restErr
}
}
}
async function lookupPRByNumber(args: {
candidates: OwnerRepo[]
number: number
ghOptions: ReturnType<typeof ghRepoExecOptions>
executionScope: string
}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> {
for (const candidate of args.candidates) {
try {
const linkedData = await getPRByNumber(
candidate,
args.number,
args.ghOptions,
args.executionScope
)
if (!linkedData) {
continue
}
return { data: linkedData, dataRepo: candidate }
} catch (err) {
if (shouldStopAfterExactLookupError(err)) {
throw err
}
// Candidate probing is best-effort; another repo may own the PR.
}
}
if (args.candidates.length > 0) {
return { data: null, dataRepo: null }
}
try {
const { stdout } = await ghExecFileAsync(
['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS],
args.ghOptions
)
return {
data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
dataRepo: null
}
} catch (err) {
if (isNoPullRequestError(err)) {
// Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo.
return { data: null, dataRepo: null }
}
throw err
}
}
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 !== '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.
*
* When `linkedPRNumber` is provided, it is the source of truth. This handles
* "create from PR" worktrees whose local branch differs from the PR head ref,
* and prevents a coalesced linked-PR refresh from fanning out an unrelated
* branch lookup result to sibling aliases.
* `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is
* used only after branch lookup misses.
*/
export async function getPRForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
fallbackPRNumber?: number | null,
options: GitHubPRBranchLookupOptions = {}
): Promise<PRInfo | null> {
const outcome = await getPRForBranchOutcome(
repoPath,
branch,
linkedPRNumber,
connectionId,
fallbackPRNumber,
options
)
return outcome.kind === 'found' ? outcome.pr : null
}
// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks.
function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null {
const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/)
return match ? { owner: match[2], repo: match[3], host: match[1] } : null
}
export async function getPRForBranchOutcome(
repoPath: string,
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
fallbackPRNumber?: number | null,
options: GitHubPRBranchLookupOptions = {}
): Promise<PRRefreshOutcome> {
const branchName = branch.replace(/^refs\/heads\//, '')
// Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible.
if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(context)
const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions)
await acquire()
try {
const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates(
repoPath,
connectionId,
localGitOptions
)
// Why: connection-backed gh runs without a repository cwd. A bare lookup
// here can honor process GH_REPO/GH_HOST and return an unrelated PR.
if (connectionId && candidates.length === 0) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
// Why (#11532): account every lookup, not just the coordinator's queue —
// `hostedReview:forBranch` reaches this directly from renderer polling and
// was spending the shared quota invisibly. headRepo is `origin`, the same
// identity the coordinator guards on.
for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) {
noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions)
}
let data: PullRequestLookupData | null = null
let dataRepo: OwnerRepo | null = null
let dataHeadRepo: OwnerRepo | null = headRepo
let pendingBranchLookupError: unknown
let hasPendingBranchLookupError = false
let currentHeadOidForMergedImplicit: string | null | undefined
let usedExactNumberLookup = false
const explicitCurrentHeadOid =
typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0
? options.currentHeadOid.trim()
: null
let confirmedContainedHeadOid: string | null = null
let headDivergedFromMergedPRAtOid: string | null = null
const mergedPRContainsHead = async (
candidate: PullRequestLookupData,
candidateRepo: OwnerRepo | null,
headOid: string | null
): Promise<MergedPRCommitMembership> => {
if (!candidateRepo || !headOid) {
return 'unknown'
}
const membership = await isCommitPartOfMergedPR({
ownerRepo: candidateRepo,
prNumber: candidate.number,
commitOid: headOid,
ghOptions
})
if (membership === 'contained') {
confirmedContainedHeadOid = headOid
}
return membership
}
const recordLinkedMergedPRDivergence = async (
candidate: PullRequestLookupData | null,
candidateRepo: OwnerRepo | null
): Promise<void> => {
if (
typeof linkedPRNumber !== 'number' ||
!candidate ||
mapPRState(candidate.state, candidate.isDraft) !== 'merged' ||
explicitCurrentHeadOid === null ||
candidate.headRefOid === explicitCurrentHeadOid
) {
return
}
const membership = await mergedPRContainsHead(
candidate,
candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url),
explicitCurrentHeadOid
)
if (membership === 'not-contained') {
// explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree.
headDivergedFromMergedPRAtOid = explicitCurrentHeadOid
}
}
const hideMergedImplicitPR = async (
candidate: PullRequestLookupData | null,
candidateRepo: OwnerRepo | null
) => {
if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) {
return false
}
// Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees.
currentHeadOidForMergedImplicit ??=
explicitCurrentHeadOid !== null
? explicitCurrentHeadOid
: await getCurrentHeadOid(repoPath, connectionId, localGitOptions)
if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) {
return false
}
// Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible.
return (
(await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !==
'contained'
)
}
if (typeof linkedPRNumber === 'number') {
usedExactNumberLookup = true
const exactLookup = await lookupPRByNumber({
candidates,
number: linkedPRNumber,
ghOptions,
executionScope
})
data = exactLookup.data
dataRepo = exactLookup.dataRepo
} else if (branchName) {
// During a rebase (detached HEAD) branch is empty; an empty --head filter makes gh return an arbitrary PR.
const branchLookup = await lookupPRByBranchName({
candidates,
headRepo,
branchName,
ghOptions,
executionScope
})
data = branchLookup.data
dataRepo = branchLookup.dataRepo
if ('pendingError' in branchLookup) {
pendingBranchLookupError = branchLookup.pendingError
hasPendingBranchLookupError = true
}
if (!data) {
// Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match.
const upstreamBranch = await getTrackedUpstreamBranch(
repoPath,
branchName,
connectionId,
localGitOptions
)
if (upstreamBranch) {
const upstreamHeadRepo =
(await getGitHubApiRepositoryForRemote(
repoPath,
upstreamBranch.remoteName,
connectionId,
localGitOptions
)) ?? headRepo
if (
upstreamHeadRepo &&
shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo)
) {
const upstreamLookup = await lookupPRByBranchName({
candidates,
headRepo: upstreamHeadRepo,
branchName: upstreamBranch.branchName,
ghOptions,
executionScope
})
data = upstreamLookup.data
dataRepo = upstreamLookup.dataRepo
if (!hasPendingBranchLookupError && 'pendingError' in upstreamLookup) {
pendingBranchLookupError = upstreamLookup.pendingError
hasPendingBranchLookupError = true
}
if (data) {
dataHeadRepo = upstreamHeadRepo
}
}
}
}
}
let mergedBranchLookupNumber: number | null = null
if (await hideMergedImplicitPR(data, dataRepo)) {
mergedBranchLookupNumber = data?.number ?? null
data = null
dataRepo = null
dataHeadRepo = headRepo
}
if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') {
usedExactNumberLookup = true
const fallbackLookup = await lookupPRByNumber({
candidates,
number: fallbackPRNumber,
ghOptions,
executionScope
})
data = fallbackLookup.data
dataRepo = fallbackLookup.dataRepo
}
if (!data) {
if (hasPendingBranchLookupError) {
return prRefreshUpstreamError(pendingBranchLookupError)
}
return { kind: 'no-pr', fetchedAt: Date.now() }
}
await recordLinkedMergedPRDivergence(data, dataRepo)
const fallbackConfirmedMergedBranch =
typeof fallbackPRNumber === 'number' &&
mergedBranchLookupNumber === fallbackPRNumber &&
data.number === fallbackPRNumber
const explicitHeadHidesMergedImplicitPR =
explicitCurrentHeadOid !== null &&
shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) &&
(await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained'
// Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs.
const shouldPreserveMergedFallback =
!explicitHeadHidesMergedImplicitPR &&
(fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true)
// Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads).
if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
// Why (#9171): on the default branch an implicit branch/fallback match must
// never surface a non-open PR — it overrides the merged-fallback
// preservation and merged-at-head carve-out on the trunk only. An exact
// linked lookup returns the linked number, so linked PRs are exempt.
if (
await shouldHideNonOpenReviewOnDefaultBranch({
state: mapPRState(data.state, data.isDraft),
reviewNumber: data.number,
linkedReviewNumber: linkedPRNumber,
branchName,
repoPath,
connectionId,
localGitOptions
})
) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) {
try {
data.stack = await getCachedGitHubPRStackSummary(
dataRepo,
data.number,
ghOptions,
executionScope
)
data.stackMetadataChecked = true
} catch {
// Stack metadata is additive; exact PR lookup remains usable without it.
}
}
const mergeable = derivePullRequestMergeable(data)
const stack =
data.stack && dataRepo
? await hydrateGitHubPRStack(
dataRepo,
data.number,
data.stack,
ghOptions,
data.updatedAt,
executionScope
)
: data.stack
const stackMergeQueueRequired =
stack && dataRepo
? (
await detectRepositoryMergeMetadata(
dataRepo,
stack.baseRefName,
ghOptions,
executionScope
)
).mergeQueueRequired
: undefined
const conflictSummary =
!connectionId &&
mergeable === 'CONFLICTING' &&
data.baseRefName &&
data.baseRefOid &&
data.headRefOid
? await getPRConflictSummary(
repoPath,
data.baseRefName,
data.baseRefOid,
data.headRefOid,
localGitOptions
)
: undefined
return {
kind: 'found',
fetchedAt: Date.now(),
pr: {
number: data.number,
title: data.title,
state: mapPRState(data.state, data.isDraft),
url: data.url,
checksStatus: deriveCheckStatus(data.statusCheckRollup),
updatedAt: data.updatedAt,
mergeable,
...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}),
...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}),
...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}),
...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined
? {
mergeQueueRequired:
stackMergeQueueRequired !== undefined
? stackMergeQueueRequired
: data.mergeQueueRequired
}
: {}),
...(data.mergeMethodSettings !== undefined
? { mergeMethodSettings: data.mergeMethodSettings }
: {}),
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
...(stack ? { stack } : {}),
headSha: data.headRefOid,
...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}),
...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}),
...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
...(data.headRefName ? { headRefName: data.headRefName } : {}),
prRepo: dataRepo ?? undefined,
headRepo: dataHeadRepo ?? undefined,
conflictSummary
}
}
} catch (err) {
return prRefreshUpstreamError(err)
} finally {
release()
}
}
const PR_CHECKS_ROLLUP_QUERY = `
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
headRefOid
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(first: 100) {
nodes {
__typename
... on CheckRun {
databaseId
name
status
conclusion
detailsUrl
url
checkSuite {
databaseId
workflowRun {
databaseId
}
}
}
... on StatusContext {
context
state
targetUrl
}
}
}
}
checkSuites(first: 100) {
nodes {
databaseId
status
conclusion
url
app {
name
slug
}
}
}
}
}
}
}
}
}
`
type GraphQLPRChecksResponse = {
data?: {
repository?: {
pullRequest?: {
headRefOid?: string | null
commits?: {
nodes?: { commit?: GraphQLPRChecksCommit | null }[] | null
} | null
} | null
} | null
} | null
}
type GraphQLPRChecksCommit = {
statusCheckRollup?: {
contexts?: {
nodes?: GraphQLStatusCheckContext[] | null
} | null
} | null
checkSuites?: {
nodes?: GraphQLCheckSuite[] | null
} | null
}
type GraphQLCheckRunContext = {
__typename: 'CheckRun'
databaseId?: number | null
name?: string | null
status?: string | null
conclusion?: string | null
detailsUrl?: string | null
url?: string | null
checkSuite?: {
databaseId?: number | null
workflowRun?: { databaseId?: number | null } | null
} | null
}
type GraphQLStatusContext = {
__typename: 'StatusContext'
context?: string | null
state?: string | null
targetUrl?: string | null
}
type GraphQLStatusCheckContext =
| GraphQLCheckRunContext
| GraphQLStatusContext
| { __typename?: string | null }
type GraphQLCheckSuite = {
databaseId?: number | null
status?: string | null
conclusion?: string | null
url?: string | null
app?: { name?: string | null; slug?: string | null } | null
}
type RestCheckRun = {
id?: number
name: string
status: string
conclusion: string | null
html_url: string
details_url: string | null
}
type RestCommitStatus = {
context?: string
state?: string
target_url?: string | null
}
type RestCheckSuite = {
id?: number | null
status: string | null
conclusion: string | null
app?: { name?: string | null; slug?: string | null } | null
}
function isGraphQLCheckRunContext(
context: GraphQLStatusCheckContext
): context is GraphQLCheckRunContext {
return context.__typename === 'CheckRun'
}
function isGraphQLStatusContext(
context: GraphQLStatusCheckContext
): context is GraphQLStatusContext {
return context.__typename === 'StatusContext'
}
function mapGraphQLCheckRunContext(context: GraphQLCheckRunContext): PRCheckDetail | null {
const name = nullableString(context.name)
if (!name) {
return null
}
const url = nullableString(context.detailsUrl) ?? nullableString(context.url)
const checkRunId = nullableNumber(context.databaseId)
const workflowRunId =
nullableNumber(context.checkSuite?.workflowRun?.databaseId) ?? parseActionsRunId(url)
return {
name,
status: mapCheckRunRESTStatus(context.status ?? ''),
conclusion: mapCheckRunRESTConclusion(context.status ?? '', context.conclusion ?? null),
url,
...(checkRunId !== null ? { checkRunId } : {}),
...(typeof workflowRunId === 'number' ? { workflowRunId } : {})
}
}
function mapGraphQLStatusContext(context: GraphQLStatusContext): PRCheckDetail | null {
const name = nullableString(context.context)
if (!name) {
return null
}
const url = nullableString(context.targetUrl)
const workflowRunId = parseActionsRunId(url)
return {
name,
status: mapCommitStatusRESTStatus(context.state ?? ''),
conclusion: mapCommitStatusRESTConclusion(context.state ?? ''),
url,
...(workflowRunId !== undefined ? { workflowRunId } : {})
}
}
function mapRestCheckRun(checkRun: RestCheckRun): PRCheckDetail {
return {
name: checkRun.name,
status: mapCheckRunRESTStatus(checkRun.status),
conclusion: mapCheckRunRESTConclusion(checkRun.status, checkRun.conclusion),
url: checkRun.details_url || checkRun.html_url || null,
...(typeof checkRun.id === 'number' ? { checkRunId: checkRun.id } : {}),
workflowRunId: parseActionsRunId(checkRun.details_url || checkRun.html_url || null)
}
}
function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null {
const name = nullableString(status.context)
if (!name) {
return null
}
const url = nullableString(status.target_url)
const workflowRunId = parseActionsRunId(url)
return {
name,
status: mapCommitStatusRESTStatus(status.state ?? ''),
conclusion: mapCommitStatusRESTConclusion(status.state ?? ''),
url,
...(workflowRunId !== undefined ? { workflowRunId } : {})
}
}
function mapGraphQLPendingApprovalCheckSuite(
ownerRepo: GitHubApiRepository,
suite: GraphQLCheckSuite,
headSha: string | null | undefined,
index: number
): PRCheckDetail {
return {
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
status: 'completed',
conclusion: 'action_required',
// Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one.
url:
nullableString(suite.url) ??
(headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null)
}
}
function mapGraphQLPRChecksResponse(
ownerRepo: GitHubApiRepository,
response: GraphQLPRChecksResponse
): PRCheckDetail[] | null {
const pullRequest = response.data?.repository?.pullRequest
if (!pullRequest) {
return null
}
const commit = pullRequest.commits?.nodes?.[0]?.commit
if (!commit) {
return []
}
const contexts = commit.statusCheckRollup?.contexts?.nodes ?? []
const checkRunContexts = contexts.filter(isGraphQLCheckRunContext)
const checkRuns = checkRunContexts
.map(mapGraphQLCheckRunContext)
.filter((check): check is PRCheckDetail => check !== null)
const checkRunNames = new Set(checkRuns.map((check) => check.name))
const checkSuiteIdsWithRuns = new Set(
checkRunContexts
.map((context) => nullableNumber(context.checkSuite?.databaseId))
.filter((id): id is number => id !== null)
)
// Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions.
const legacyStatuses = contexts
.filter(isGraphQLStatusContext)
.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)
})
.map((suite, index) =>
mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index)
)
return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
}
async function getPRChecksViaRestFallback(
ownerRepo: GitHubApiRepository,
headSha: string | undefined,
ghOptions: GhExecOptions,
noCache?: boolean
): Promise<PRCheckDetail[] | null> {
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
)
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[] = []
try {
const statusResult = await ghExecFileAsync(
[
'api',
...cacheArgs,
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100`
],
ghOptions
)
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) {
// Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status 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
)
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
} catch (err) {
console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err)
return null
} finally {
release()
}
}
/**
* Get detailed check statuses for a PR.
* Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses
* arrive in one cached request; suite-only approval blockers are included too.
*/
export async function getPRChecks(
repoPath: string,
prNumber: number,
headSha?: string,
prRepo?: GitHubApiRepository | null,
options?: { noCache?: boolean },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRCheckDetail[]> {
void headSha
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (connectionId && !ownerRepo) {
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
const fallbackToPRChecks = async (): Promise<PRCheckDetail[]> => {
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
await acquire()
try {
const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link']
if (ownerRepo) {
fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => {
const { stderr } = extractExecError(err)
// Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure.
if (stderr.toLowerCase().includes('no checks reported')) {
return { stdout: '[]', stderr }
}
throw err
})
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
return data.map((d) => ({
name: d.name,
status: mapCheckStatus(d.state),
conclusion: mapCheckConclusion(d.state),
url: d.link || null,
workflowRunId: parseActionsRunId(d.link)
}))
} finally {
release()
}
}
if (ownerRepo) {
let canUseGraphQLRollup = true
try {
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
} catch (err) {
canUseGraphQLRollup = false
console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err)
}
if (canUseGraphQLRollup) {
await acquire()
try {
// Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
const { stdout } = await ghExecFileAsync(
[
'api',
'graphql',
...cacheArgs,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`,
'-F',
`pr=${prNumber}`,
'-f',
`query=${PR_CHECKS_ROLLUP_QUERY}`
],
ghOptions
)
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
const checks = mapGraphQLPRChecksResponse(
ownerRepo,
JSON.parse(stdout) as GraphQLPRChecksResponse
)
if (checks !== null) {
return checks
}
} catch (err) {
// Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable.
console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err)
} finally {
release()
}
}
const restChecks = await getPRChecksViaRestFallback(
ownerRepo,
headSha,
ghOptions,
options?.noCache
)
if (restChecks !== null) {
return restChecks
}
}
try {
return await fallbackToPRChecks()
} catch (err) {
console.warn('getPRChecks failed:', err)
throw err
}
}
function getPendingApprovalCheckSuiteName(
suite: {
id?: number | null
databaseId?: number | null
app?: { name?: string | null; slug?: string | null } | null
},
headSha: string | null | undefined,
index: number
): string {
const appName = suite.app?.name ?? suite.app?.slug ?? null
const rawSuiteId = suite.databaseId ?? suite.id
const suiteId =
typeof rawSuiteId === 'number' && Number.isFinite(rawSuiteId) ? `#${rawSuiteId}` : null
if (appName && suiteId) {
return `${appName} ${suiteId}`
}
if (appName) {
return appName
}
if (suiteId) {
return suiteId
}
return `${headSha?.slice(0, 12) ?? 'check-suite'}:${index + 1}`
}
function getPendingApprovalCheckSuiteUrl(
ownerRepo: GitHubApiRepository,
headSha: string,
suiteId: number | null | undefined
): string {
const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks`
return typeof suiteId === 'number' && Number.isFinite(suiteId)
? `${base}#check-suite-${suiteId}`
: base
}
function nullableString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function nullableNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] {
if (!Array.isArray(raw)) {
return []
}
return raw
.filter((annotation): annotation is Record<string, unknown> => Boolean(annotation))
.map((annotation) => ({
path: nullableString(annotation.path),
startLine: nullableNumber(annotation.start_line),
endLine: nullableNumber(annotation.end_line),
annotationLevel: nullableString(annotation.annotation_level),
title: nullableString(annotation.title),
message: nullableString(annotation.message) ?? '',
rawDetails: nullableString(annotation.raw_details)
}))
}
function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] {
if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) {
return []
}
const jobs = (raw as { jobs: unknown[] }).jobs
.filter((job): job is Record<string, unknown> => Boolean(job))
.map((job) => ({
id: nullableNumber(job.id),
name: nullableString(job.name) ?? 'Unnamed job',
status: nullableString(job.status),
conclusion: nullableString(job.conclusion),
startedAt: nullableString(job.started_at),
completedAt: nullableString(job.completed_at),
url: nullableString(job.html_url),
logTail: null,
steps: Array.isArray(job.steps)
? job.steps
.filter((step): step is Record<string, unknown> => Boolean(step))
.map((step) => ({
name: nullableString(step.name) ?? 'Unnamed step',
status: nullableString(step.status),
conclusion: nullableString(step.conclusion),
startedAt: nullableString(step.started_at),
completedAt: nullableString(step.completed_at)
}))
: []
}))
const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : []
return exactMatches.length > 0 ? exactMatches : jobs
}
function isCheckJobFailureState(state: string | null | undefined): boolean {
return (
state === 'failure' ||
state === 'failed' ||
state === 'action_required' ||
state === 'cancelled' ||
state === 'stale' ||
state === 'startup_failure' ||
state === 'timed_out'
)
}
function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null {
if (job.id === null) {
return null
}
return `${job.id}:${job.completedAt ?? ''}`
}
async function attachFailedJobLogTails(
jobs: PRCheckRunDetails['jobs'],
ownerRepo: GitHubApiRepository,
ghOptions: GhExecOptions
): Promise<void> {
const failedJobs = jobs
.filter((job) => {
const state = job.conclusion ?? job.status
return job.id !== null && isCheckJobFailureState(state)
})
.slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT)
// Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads.
for (const job of failedJobs) {
const jobCacheKey = getCheckJobLogTailCacheKey(job)
const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null
if (!cacheKey) {
continue
}
if (prCheckLogTailCache.has(cacheKey)) {
job.logTail = prCheckLogTailCache.get(cacheKey) ?? null
continue
}
try {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/jobs/${job.id}/logs`],
ghOptions
)
job.logTail = sliceCheckLogTail(stdout)
} catch (err) {
rethrowCheckDetailsAbort(ghOptions.signal, err)
console.warn('getPRCheckDetails workflow job log fetch failed:', err)
job.logTail = null
}
setPrCheckLogTailCache(cacheKey, job.logTail)
}
}
function getWorkflowRunIdFromCheckRun(
checkRun: Record<string, unknown> | null
): number | undefined {
const checkSuite = checkRun?.check_suite
if (!checkSuite || typeof checkSuite !== 'object') {
return undefined
}
const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run
if (!workflowRun || typeof workflowRun !== 'object') {
return undefined
}
const id = (workflowRun as { id?: unknown }).id
return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined
}
export async function getPRCheckDetails(
repoPath: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubApiRepository | null
},
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
callerSignal?: AbortSignal
): Promise<PRCheckRunDetails | null> {
const controller = new AbortController()
let hostDeadlineExpired = false
const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason)
if (callerSignal?.aborted) {
forwardCallerAbort()
} else {
callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true })
}
const hostDeadline = setTimeout(() => {
hostDeadlineExpired = true
controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE))
}, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS)
let acquired = false
try {
const resolved = await waitForCheckDetailsResolution(
resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions),
controller.signal
)
if (!resolved.ownerRepo) {
return null
}
const ownerRepo = resolved.ownerRepo
const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal }
await acquire(controller.signal)
acquired = true
let checkRun: Record<string, unknown> | null = null
let annotations: PRCheckRunDetails['annotations'] = []
if (args.checkRunId) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`],
ghOptions
)
checkRun = JSON.parse(stdout) as Record<string, unknown>
try {
const annotationsResult = await ghExecFileAsync(
[
'api',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20`
],
ghOptions
)
annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout))
} catch (err) {
rethrowCheckDetailsAbort(controller.signal, err)
console.warn('getPRCheckDetails annotations fetch failed:', err)
}
}
const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun)
let jobs: PRCheckRunDetails['jobs'] = []
if (workflowRunId) {
try {
const { stdout } = await ghExecFileAsync(
[
'api',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100`
],
ghOptions
)
jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName)
await attachFailedJobLogTails(jobs, ownerRepo, ghOptions)
} catch (err) {
rethrowCheckDetailsAbort(controller.signal, err)
console.warn('getPRCheckDetails workflow jobs fetch failed:', err)
}
}
const output =
checkRun?.output && typeof checkRun.output === 'object'
? (checkRun.output as Record<string, unknown>)
: null
return {
name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check',
status: nullableString(checkRun?.status),
conclusion: nullableString(checkRun?.conclusion),
url: nullableString(checkRun?.html_url) ?? args.url ?? null,
detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null,
startedAt: nullableString(checkRun?.started_at),
completedAt: nullableString(checkRun?.completed_at),
title: nullableString(output?.title),
summary: nullableString(output?.summary),
text: nullableString(output?.text),
annotations,
jobs
}
} catch (err) {
console.warn('getPRCheckDetails failed:', err)
if (hostDeadlineExpired && !callerSignal?.aborted) {
throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)
}
throw err
} finally {
clearTimeout(hostDeadline)
callerSignal?.removeEventListener('abort', forwardCallerAbort)
if (acquired) {
release()
}
}
}
function parseActionsRunId(url: string | null | undefined): number | undefined {
if (!url) {
return undefined
}
const match = /\/actions\/runs\/(\d+)(?:\/|$)/.exec(url)
if (!match) {
return undefined
}
const id = Number(match[1])
return Number.isSafeInteger(id) ? id : undefined
}
export async function rerunPRChecks(
repoPath: string,
prNumber: number,
options: {
headSha?: string
failedOnly?: boolean
prRepo?: GitHubApiRepository | null
} = {},
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubRerunPRChecksResult> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
options.prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
const checks = await getPRChecks(
repoPath,
prNumber,
options.headSha,
ownerRepo,
{ noCache: true },
connectionId,
localGitOptions
)
const candidates = options.failedOnly
? checks.filter((check) =>
['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '')
)
: checks
const workflowRunIds = new Set(
candidates
.map((check) => check.workflowRunId ?? parseActionsRunId(check.url))
.filter((id): id is number => typeof id === 'number')
)
const checkRunIds = new Set(
candidates
.filter((check) => !check.workflowRunId && !parseActionsRunId(check.url))
.map((check) => check.checkRunId)
.filter((id): id is number => typeof id === 'number')
)
if (workflowRunIds.size === 0 && checkRunIds.size === 0) {
return {
ok: false,
error: options.failedOnly
? 'No failed GitHub Actions checks to rerun.'
: 'No rerunnable checks found.'
}
}
let count = 0
await acquire()
try {
for (const runId of workflowRunIds) {
const endpoint = options.failedOnly
? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs`
: `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun`
await ghExecFileAsync(['api', '-X', 'POST', endpoint], {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
count += 1
}
for (const checkRunId of checkRunIds) {
await ghExecFileAsync(
[
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest`
],
{ ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } }
)
count += 1
}
return { ok: true, count }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: classifyGhError(message).message }
} finally {
release()
}
}
// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them).
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
}
}
}
}
}
}
}`
/**
* 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[] }
comments?: { nodes: GQLIssueComment[] }
reviews?: { nodes: GQLIssueComment[] }
}
}
}
}
const pullRequest = threadsData.data.repository.pullRequest
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
}
graphQLReviewSummaries = (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)
})
)
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()
}
}
export async function setPRCommentReaction(
repoPath: string,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const mutation = reacted ? 'addReaction' : 'removeReaction'
const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
${mutation}(input: { subjectId: $subjectId, content: $content }) {
subject { id }
}
}`
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return false
}
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
if (guard.blocked) {
console.warn(
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
)
return false
}
await acquire()
try {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`subjectId=${reactionSubjectId}`,
'-f',
`content=${toGraphQLReactionContent(content)}`
],
ghOptions
)
return true
} catch (err) {
console.warn(`${mutation} failed:`, err)
return false
} finally {
release()
}
}
/**
* Mark or unmark a PR file as viewed via GitHub's GraphQL API.
*/
export async function setPRFileViewed(args: {
repoPath: string
connectionId?: string | null
localGitOptions?: LocalGitExecOptions
prRepo?: GitHubApiRepository | null
pullRequestId: string
path: string
viewed: boolean
}): Promise<boolean> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
args.repoPath,
args.prRepo,
args.connectionId,
args.localGitOptions
)
if (!ownerRepo) {
return false
}
const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed'
const query = `mutation($pullRequestId: ID!, $path: String!) {
${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) {
pullRequest { id }
}
}`
await acquire()
try {
await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`pullRequestId=${args.pullRequestId}`,
'-f',
`path=${args.path}`
],
ghOptions
)
return true
} catch (err) {
console.warn(`${mutation} failed:`, err)
return false
} finally {
release()
}
}
/**
* Resolve or unresolve a PR review thread via GraphQL.
*/
export async function resolveReviewThread(
repoPath: string,
threadId: string,
resolve: boolean,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'
const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }`
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return false
}
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
if (guard.blocked) {
console.warn(
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
)
return false
}
await acquire()
try {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
await ghExecFileAsync(
['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`],
ghOptions
)
return true
} catch (err) {
console.warn(`${mutation} failed:`, err)
return false
} finally {
release()
}
}
function mapReviewCommentResponse(
data: {
id?: number
node_id?: string | null
user: { login: string; avatar_url: string; type?: string } | null
body?: string
created_at?: string
html_url?: string
path?: string
line?: number | null
},
body: string,
path?: string,
line?: number,
startLine?: number,
threadId?: string
): PRComment {
return {
id: data.id ?? Date.now(),
reactionSubjectId: data.node_id?.trim() || undefined,
author: data.user?.login ?? 'You',
authorAvatarUrl: data.user?.avatar_url ?? '',
body: data.body ?? body,
createdAt: data.created_at ?? new Date().toISOString(),
url: data.html_url ?? '',
isBot: data.user?.type === 'Bot',
path: data.path ?? path,
line: data.line ?? line,
startLine,
threadId
}
}
export async function addPRReviewCommentReply(
repoPath: string,
prNumber: number,
commentId: number,
body: string,
threadId?: string,
path?: string,
line?: number,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubCommentResult> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/comments/${commentId}/replies`,
'--raw-field',
`body=${body}`
],
ghOptions
)
const data = JSON.parse(stdout) as Parameters<typeof mapReviewCommentResponse>[0]
if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) {
return { ok: false, error: 'Unexpected response from GitHub' }
}
return {
ok: true,
comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGhError(stderr).message }
} finally {
release()
}
}
export async function addPRReviewComment(
args: GitHubPRReviewCommentInput & {
connectionId?: string | null
localGitOptions?: LocalGitExecOptions
}
): Promise<GitHubCommentResult> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
args.repoPath,
args.prRepo,
args.connectionId,
args.localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const fields = [
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${args.prNumber}/comments`,
'--raw-field',
`body=${args.body}`,
'--raw-field',
`commit_id=${args.commitId}`,
'--raw-field',
`path=${args.path}`,
'--field',
`line=${String(args.line)}`,
'--raw-field',
'side=RIGHT'
]
if (typeof args.startLine === 'number' && args.startLine !== args.line) {
fields.push(
'--field',
`start_line=${String(args.startLine)}`,
'--raw-field',
'start_side=RIGHT'
)
}
const { stdout } = await ghExecFileAsync(fields, ghOptions)
return {
ok: true,
comment: mapReviewCommentResponse(
JSON.parse(stdout),
args.body,
args.path,
args.line,
args.startLine
)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGhError(stderr).message }
} finally {
release()
}
}
/**
* Merge a PR by number using gh CLI.
* method: 'merge' | 'squash' | 'rebase' (default: 'squash')
*/
export async function mergePR(
repoPath: string,
prNumber: number,
method: 'merge' | 'squash' | 'rebase' = 'squash',
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
let concurrencySlotHeld = true
try {
let stackSummary: GitHubPRStack | undefined
let stackHeadSha: string | undefined
try {
const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions)
stackSummary = restData?.stack
stackHeadSha = restData?.headRefOid
} catch {
// GitHub remains authoritative when stack metadata cannot be read.
}
if (stackSummary) {
const mergeMetadata = await detectRepositoryMergeMetadata(
ownerRepo,
stackSummary.baseRefName,
ghOptions,
githubPRStackExecutionScope(connectionId, localGitOptions)
)
release()
concurrencySlotHeld = false
return await mergeGitHubPRStack({
repository: ownerRepo,
prNumber,
method,
mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge',
headSha: stackHeadSha,
ghOptions
})
}
const mergeBlocker = await getPRMergeBlocker(
repoPath,
prNumber,
ownerRepo,
ghOptions,
connectionId,
localGitOptions
)
if (mergeBlocker) {
return { ok: false, error: mergeBlocker }
}
// Don't use --delete-branch: it deletes the local branch, which fails while the worktree is checked out on it.
const args = ['pr', 'merge', String(prNumber), `--${method}`]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: message }
} finally {
if (concurrencySlotHeld) {
release()
}
}
}
export async function setPRAutoMerge(
repoPath: string,
prNumber: number,
enabled: boolean,
method: GitHubPRMergeMethod = 'squash',
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
if (enabled) {
return await enablePRAutoMerge(prNumber, method, ownerRepo, ghOptions)
}
const args = ['pr', 'merge', String(prNumber), '--disable-auto']
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: classifySetAutoMergeError(message) }
} finally {
release()
}
}
// Why: GitHub rejects auto-merge on an already-mergeable PR ("clean status"); surface an actionable message instead of the raw error.
function classifySetAutoMergeError(message: string): string {
if (/in clean status/i.test(message)) {
return 'This pull request can already be merged. Use Merge instead of auto-merge.'
}
return classifyGhError(message).message
}
type PRAutoMergeIdentity = {
id?: string
headRefOid?: string
baseRefName?: string
}
async function getPRAutoMergeIdentity(
prNumber: number,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<PRAutoMergeIdentity | null> {
const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const { stdout } = await ghExecFileAsync(args, ghOptions)
const data = JSON.parse(stdout) as PRAutoMergeIdentity
return {
id: typeof data.id === 'string' ? data.id : undefined,
headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined,
baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined
}
}
async function runPRAutoMergeCommand(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<void> {
const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
}
async function shouldUseMergeQueueAutoMerge(
pr: PRAutoMergeIdentity,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<boolean> {
if (!ownerRepo || !pr.baseRefName) {
return false
}
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions)
return mergeMetadata.mergeQueueRequired === true
}
async function enablePRAutoMerge(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<{ ok: true } | { ok: false; error: string }> {
if (ownerRepo) {
try {
const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions)
if (restData?.stack) {
return {
ok: false,
error: 'GitHub does not support auto-merge for stacked pull requests.'
}
}
} catch {
// GitHub remains authoritative when stack metadata cannot be read.
}
}
const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions)
if (!pr?.id) {
return { ok: false, error: 'Could not resolve GitHub pull request ID' }
}
const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)
if (useMergeQueue) {
await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions)
return { ok: true }
}
const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) {
enablePullRequestAutoMerge(input: {
pullRequestId: $pullRequestId,
mergeMethod: $mergeMethod,
expectedHeadOid: $expectedHeadOid
}) {
pullRequest { id }
}
}`
const args = [
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`pullRequestId=${pr.id}`,
'-f',
`mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}`
]
if (pr.headRefOid) {
args.push('-f', `expectedHeadOid=${pr.headRefOid}`)
}
// Why: `gh pr merge --auto` can merge immediately; this mutation only creates the auto-merge request, letting branch requirements gate it.
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
}
async function getPRMergeBlocker(
repoPath: string,
prNumber: number,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string | null> {
if (!ownerRepo) {
return null
}
try {
const pr = await getPRByNumber(
ownerRepo,
prNumber,
ghOptions,
githubPRStackExecutionScope(connectionId, localGitOptions)
)
if (!pr) {
return null
}
if (pr.reviewDecision === 'REVIEW_REQUIRED') {
return 'This pull request requires review approval before it can be merged.'
}
if (pr.reviewDecision === 'CHANGES_REQUESTED') {
return 'This pull request has requested changes and cannot be merged yet.'
}
if (pr.mergeQueueRequired === true) {
return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.'
}
// Why: conflict summaries shell out to local git; skip for SSH repos until that helper routes through the SSH provider.
if (
connectionId ||
pr.mergeable !== 'CONFLICTING' ||
!pr.baseRefName ||
!pr.baseRefOid ||
!pr.headRefOid
) {
return null
}
const summary = await getPRConflictSummary(
repoPath,
pr.baseRefName,
pr.baseRefOid,
pr.headRefOid,
localGitOptions
)
return formatMergeConflictBlocker(pr.baseRefName, summary)
} catch {
// Why: conflict preflight should improve stale UI diagnostics, not block merge on a transient lookup failure.
return null
}
}
function formatMergeConflictBlocker(
baseRefName: string,
summary: PRConflictSummary | undefined
): string {
const heading = 'This pull request has merge conflicts and cannot be merged yet.'
if (!summary || summary.files.length === 0) {
return `${heading}\nUpdate the branch with ${baseRefName} and resolve the conflicts before merging.`
}
const files = summary.files.map((file) => `- ${file}`).join('\n')
const behind = `${summary.commitsBehind} commit${summary.commitsBehind === 1 ? '' : 's'} behind ${baseRefName}`
return `${heading}\n${behind} (base commit: ${summary.baseCommit}).\n\nConflicting files:\n${files}`
}
export async function updatePRState(
repoPath: string,
prNumber: number,
updates: GitHubPullRequestStateUpdate,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
// Why: gh's PR commands use GitHub's supported reopen flow; REST state PATCH can 422 on reopen.
await ghExecFileAsync(
['pr', cmd, String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`],
{
...ghOptions
}
)
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: classifyGhError(message).message }
} finally {
release()
}
}
export async function requestPRReviewers(
repoPath: string,
prNumber: number,
reviewers: string[],
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
if (logins.length === 0) {
return { ok: false, error: 'Enter at least one reviewer' }
}
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: message }
} finally {
release()
}
}
export async function removePRReviewers(
repoPath: string,
prNumber: number,
reviewers: string[],
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
if (logins.length === 0) {
return { ok: false, error: 'Enter at least one reviewer' }
}
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: message }
} finally {
release()
}
}
/**
* Update a PR's title.
*/
export async function updatePRTitle(
repoPath: string,
prNumber: number,
title: string,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return false
}
await acquire()
try {
const args = ['pr', 'edit', String(prNumber), '--title', title]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions
})
return true
} catch (err) {
console.warn('updatePRTitle failed:', err)
return false
} finally {
release()
}
}
export async function updatePRDetails(
repoPath: string,
prNumber: number,
updates: { title?: string; body?: string },
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
const fields: string[] = []
if (updates.title !== undefined) {
const title = updates.title.trim()
if (!title) {
return { ok: false, error: 'Title is required' }
}
fields.push(`title=${title}`)
}
if (updates.body !== undefined) {
fields.push(`body=${updates.body}`)
}
if (fields.length === 0) {
return { ok: true }
}
await acquire()
try {
await ghExecFileAsync(
[
'api',
'-X',
'PATCH',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`,
...fields.flatMap((field) => ['--raw-field', field])
],
ghOptions
)
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: classifyGhError(message).message }
} finally {
release()
}
}