/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps acquire/release and error handling consistent. */ import type { ClassifiedError } from '../../shared/classified-error' import type { GitHubRerunPRChecksResult, PRCheckDetail, PRCheckRunDetails } from '../../shared/github/check-types' import type { GitHubCommentResult, GitHubPRReviewCommentInput, GitHubReactionContent, PRComment } from '../../shared/github/comment-types' import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' import type { GitHubPRMergeMethod, GitHubPRMergeMethodSettings, GitHubPRStack, GitHubViewer, PRConflictSummary, PRInfo, PRMergeableState, PRReviewDecision } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' import type { IssueSourcePreference } from '../../shared/repo-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github/pull-request-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 } from './issues' export { createIssue } from './issue-create' export { updateIssue } from './issue-update' export { addIssueComment } from './issue-comment' export { listLabels, listAssignableUsers } from './issue-field-options' 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 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() 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(operation: Promise, signal: AbortSignal): Promise { 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 STACK_METADATA_UNAVAILABLE_ERROR = 'Could not verify GitHub pull request stack metadata. Refresh and try again.' const prStackSummaryCache = new Map< string, { value: GitHubPRStack | undefined; expiresAt: number } >() const prStackSummaryInFlight = new Map>() 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 ): Promise { 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 { const errorType = classifyPRRefreshError(err) const outcome: Extract = { 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 { 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 { 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 { 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 { 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 { 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 // 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): 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 ): Pick { 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 | 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 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 const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author) if (nested) { users.push(nested) } } } return users } function latestReviewsFromUnknown(value: unknown): NonNullable { if (!Array.isArray(value)) { return [] } const reviews: NonNullable = [] for (const entry of value) { if (typeof entry !== 'object' || entry === null) { continue } const raw = entry as Record 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 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 // 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, 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 { 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 { 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 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) } // 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> { 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 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 { 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 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, 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) } async function fetchPullRequestWorkItemFromCandidates( repoPath: string, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {}, preference?: IssueSourcePreference ): Promise { 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 { 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 { 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[]) // 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[]) .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 { 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 => { 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[]) .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 => { 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[]) .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> { 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 { 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 { 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[] = [] // 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 { 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 { 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 { 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 { 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 { 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 { 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 = { 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 { 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 { 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 { 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 { 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 ): Promise { 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 ): Promise { 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 } type TrackedUpstreamSnapshotProbeResult = { cacheable: boolean gitConfigSignature?: string probeFailed: boolean upstreamsByBranchName: Map } const trackedUpstreamSnapshotCache = new Map() const trackedUpstreamSnapshotInFlight = new Map< string, Promise >() const trackedUpstreamSnapshotGenerations = new Map() 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 { 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 { 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 ): Map { // 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 { 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 }> { 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 { const upstreamsByBranchName = new Map() 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 } } function isPositiveSafeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 } function isGitObjectId(value: unknown): value is string { return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) } function isUsableRestStackMetadata(value: unknown): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false } const stack = value as { number?: unknown position?: unknown size?: unknown base?: unknown } if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) { return false } const base = stack.base as { ref?: unknown; sha?: unknown } return ( isPositiveSafeInteger(stack.number) && isPositiveSafeInteger(stack.position) && isPositiveSafeInteger(stack.size) && stack.position <= stack.size && typeof base.ref === 'string' && base.ref.trim().length > 0 && (base.sha === undefined || isGitObjectId(base.sha)) ) } async function getRestPRByNumber( ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType, options: { requireUsableStackMetadata?: boolean } = {} ): Promise { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) const parsed = JSON.parse(stdout) as unknown if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error('invalid response shape') } const restData = parsed as RestPullRequest const mapped = mapRestPullRequest(restData) if ( options.requireUsableStackMetadata && restData.stack !== undefined && restData.stack !== null ) { // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe. if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) { throw new Error('malformed stack') } if (!isGitObjectId(restData.head?.sha)) { throw new Error('missing head SHA') } } return mapped } 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, executionScope: string ): Promise { 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, executionScope: string, knownPullRequestData?: PullRequestLookupData | null ): Promise { 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 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 { 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 { 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 => { 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 => { 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 { 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 { 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 => { 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 => 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 => 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 => 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 { 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 | 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 { 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 | 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 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) : 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 { 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 { 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 { 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 { 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 { 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 { 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[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 { 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 restData: PullRequestLookupData try { restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions, { requireUsableStackMetadata: true }) } catch (err) { const diagnostic = err instanceof SyntaxError ? 'invalid JSON response' : err instanceof Error ? err.message : String(err) console.warn( `mergePR stack metadata probe failed for ${ownerRepo.owner}/${ownerRepo.repo}#${String(prNumber)}:`, diagnostic ) return { ok: false, error: STACK_METADATA_UNAVAILABLE_ERROR } } if (restData.stack) { const mergeMetadata = await detectRepositoryMergeMetadata( ownerRepo, restData.stack.baseRefName, ghOptions, githubPRStackExecutionScope(connectionId, localGitOptions) ) release() concurrencySlotHeld = false return await mergeGitHubPRStack({ repository: ownerRepo, prNumber, method, mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', headSha: restData.headRefOid, 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 { 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 { 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 { 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 { 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 { 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() } }