Files
orca/src/main/github/client.ts
T
fsdwenandJinjing 78d2b958bf fix(issues): replace cursor-based pagination with page-number Search API (#8680)
* fix(issues): replace cursor-based pagination with page-number Search API

Problem
=======
Issue pagination (#8649) had two bugs:
1. Pages 6-16 were unreachable — clicking page 16 highlighted page 5;
   clicking 6/7 did nothing. The old cursor-based approach
   (updated:<CURSOR) broke with Search API's relevance sorting —
   pages after the first few returned no items even though more
   issues existed.
2. Issue numbers appeared out of order on loaded pages (e.g. #1082
   between #1308 and #1499), because client-side sort used
   updatedAt instead of issue number.

Root Cause
==========
The pagination used two separate GitHub API strategies:
- Initial page 0 load: REST endpoints (repos/:owner/:repo/issues,
  repos/:owner/:repo/pulls) sorted by updatedAt
- Subsequent pages: Search API with cursor (updated:<DATE)

These two sources returned items in different orders, causing items
to go missing or appear on wrong pages across page boundaries.

Solution
========
1. Unified on GitHub Search API for all pages — initial load and
   pagination both use search/issues?q=...&page=N, eliminating the
   REST-vs-Search inconsistency.
2. Changed from cursor-based (update:<DATE) to page-number-based
   pagination (page=N), which the Search API supports natively.
3. Switched client-side sort from updatedAt to issue number
   (sortWorkItemsByNumber), matching GitHub's default Issues view.
4. Parallelized page fetches in handleLoadNextPage — clicking page
   16 now fetches all intermediate pages concurrently (~2s) instead
   of sequentially (~30s).
5. Cleaned up dead legacy gh issue list / gh pr list code path,
   extracted quoteForSearch helper, shortened overlong comments.

Files changed: 11 files, +140/-127 lines

Closes #8649

* chore: remove unrelated merge formatting

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-07-15 18:52:37 -07:00

4972 lines
160 KiB
TypeScript

/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps the
concurrency acquire/release pattern and error handling consistent across operations. */
import type {
ClassifiedError,
GitPushTarget,
IssueSourcePreference,
ListWorkItemsResult,
PRInfo,
PRConflictSummary,
PRRefreshOutcome,
PRMergeableState,
PRReviewDecision,
PRCheckDetail,
PRCheckRunDetails,
GitHubCommentResult,
GitHubPRReviewCommentInput,
PRComment,
GitHubViewer,
GitHubWorkItem,
GitHubPullRequestStateUpdate,
GitHubRerunPRChecksResult,
GitHubPRMergeMethod,
GitHubPRMergeMethodSettings
} from '../../shared/types'
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds'
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 './check-job-log-tail-slice'
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,
extractExecError,
acquire,
release,
getOwnerRepo,
getIssueOwnerRepo,
getOwnerRepoForRemote,
resolvePRRepositoryCandidates,
resolveIssueSource,
classifyGhError,
classifyListIssuesError,
ghRepoExecOptions,
githubRepoContext,
getRemoteUrlForRepo,
type LocalGitExecOptions,
type OwnerRepo
} from './gh-utils'
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 { readLocalGitConfigSignature } from './local-git-config-signature'
import {
getRememberedGhCwdResolutionFailure,
isGhCwdRepoResolutionFailure,
rememberGhCwdResolutionFailure
} from './gh-cwd-repo-negative-cache'
import type { GitHubRepoContext } from './github-repository-identity'
import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository'
export { _resetOwnerRepoCache } from './gh-utils'
export {
getIssue,
listIssues,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers
} from './issues'
import {
mapCheckRunRESTStatus,
mapCheckRunRESTConclusion,
mapCommitStatusRESTStatus,
mapCommitStatusRESTConclusion,
mapCheckStatus,
mapCheckConclusion,
mapPRState,
deriveCheckStatus
} from './mappers'
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions'
import {
getRateLimit,
noteRateLimitSpend,
rateLimitGuard,
type RateLimitBucketKind
} from './rate-limit'
type GhExecOptions = ReturnType<typeof ghRepoExecOptions>
type HostedReviewLocalGitOptions = ReturnType<typeof getHostedReviewLocalGitOptions>
const ORCA_REPO = 'stablyai/orca'
const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5
// Why: each entry holds up to 16KB of log text; bound the cache so a long
// session reviewing many failing checks can't grow it without limit.
const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128
const prCheckLogTailCache = new Map<string, string | null>()
function hostedReviewLocalGitOptionArgs(
options: HostedReviewExecutionOptions = {}
): [] | [HostedReviewLocalGitOptions] {
return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : []
}
function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void {
prCheckLogTailCache.set(cacheKey, logTail)
while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
const oldestKey = prCheckLogTailCache.keys().next().value
if (oldestKey === undefined) {
break
}
prCheckLogTailCache.delete(oldestKey)
}
}
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 }
>()
export function _resetMergeQueueCacheForTests(): void {
repositoryMergeMetadataCache.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): Promise<void> {
await getRateLimit()
const guard = rateLimitGuard(bucket)
if (guard.blocked) {
throw new Error(
`GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}`
)
}
}
function classifyPRRefreshError(
err: unknown
): Extract<PRRefreshOutcome, { kind: 'upstream-error' }>['errorType'] {
const message = err instanceof Error ? err.message : String(err)
const lower = message.toLowerCase()
if (lower.includes('rate limit')) {
return 'rate_limited'
}
if (
lower.includes('timeout') ||
lower.includes('no such host') ||
lower.includes('network') ||
lower.includes('could not resolve host')
) {
return 'network'
}
if (lower.includes('http 403') || lower.includes('resource not accessible')) {
return 'permission'
}
if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) {
return 'repo_unavailable'
}
return /auth|login|credential/i.test(message) ? 'auth' : 'unknown'
}
function safePRRefreshErrorMessage(
errorType: Extract<PRRefreshOutcome, { kind: 'upstream-error' }>['errorType']
): string {
switch (errorType) {
case 'rate_limited':
return 'GitHub rate limit is low. Try again after the limit resets.'
case 'auth':
return 'GitHub authentication is unavailable. Check your gh login.'
case 'network':
return 'GitHub is unreachable right now. Check your network and try again.'
case 'permission':
return 'GitHub did not allow access to this pull request.'
case 'repo_unavailable':
return 'The GitHub repository is unavailable or cannot be resolved.'
case 'gh_unavailable':
return 'GitHub CLI is unavailable.'
case 'unknown':
return 'GitHub pull request refresh failed.'
}
}
function prRefreshUpstreamError(
err: unknown
): Extract<PRRefreshOutcome, { kind: 'upstream-error' }> {
const errorType = classifyPRRefreshError(err)
return {
kind: 'upstream-error',
errorType,
message: safePRRefreshErrorMessage(errorType),
fetchedAt: Date.now()
}
}
function isNoPullRequestError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return /no pull requests? found|could not find.*pull request/i.test(message)
}
/**
* Check if the authenticated user has starred the Orca repo.
* Returns true if starred, false if not, null if unable to determine (gh unavailable).
*/
export async function checkOrcaStarred(): Promise<boolean | null> {
await acquire()
try {
const { stdout, stderr } = await execFileAsync(
'gh',
['api', '--include', `user/starred/${ORCA_REPO}`],
{ encoding: 'utf-8' }
)
const response = `${stdout ?? ''}\n${stderr ?? ''}`
if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
return true
}
return null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 404 means the user hasn't starred — the only expected "no" answer
if (message.includes('HTTP 404')) {
return false
}
// Anything else (gh not installed, not authenticated, network issue)
return null
} finally {
release()
}
}
function pickPushRemoteUrl(args: {
originUrl: string | null
cloneUrl: string
sshUrl: string
}): string {
const { originUrl, cloneUrl, sshUrl } = args
if (originUrl && (/^(git@|ssh:)/.test(originUrl) || originUrl.includes('ssh.github.com'))) {
return sshUrl
}
return cloneUrl
}
function sanitizeRemoteName(owner: string, repo: string): string {
const slug = `${owner}-${repo}`
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
return slug ? `pr-${slug}` : 'pr-head'
}
/**
* A fork push target plus the PR's `maintainer_can_modify` flag. The flag rides
* alongside the target (rather than inside {@link GitPushTarget}) so it never
* leaks into the persisted, validated 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
}
export async function getPullRequestPushTarget(
repoPath: string,
prNumber: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PullRequestPushTarget | null> {
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(context)
const { candidates } = await resolvePRRepositoryCandidates(
repoPath,
connectionId,
localGitOptions
)
if (candidates.length === 0) {
return null
}
await acquire()
try {
let prStdout = ''
for (const candidate of candidates) {
try {
const { stdout } = await ghExecFileAsync(
['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`],
{
...ghOptions
}
)
prStdout = stdout
break
} catch (error) {
// Why: in fork workflows `origin` is often the contributor fork while
// the PR number belongs to `upstream`; probe all known PR repos before
// deciding this PR number is unavailable.
if (isNotFoundGhError(error)) {
continue
}
throw error
}
}
if (!prStdout) {
return null
}
const origin = await getOwnerRepoForRemote(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 &&
origin.owner.toLowerCase() === owner.toLowerCase() &&
origin.repo.toLowerCase() === repo.toLowerCase()
) {
return {
pushTarget: { remoteName: 'origin', branchName },
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
}
}
let originUrl: string | null = null
try {
const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin')
originUrl = rawOriginUrl?.trim() || null
} catch {
originUrl = null
}
return {
pushTarget: {
remoteName: sanitizeRemoteName(owner, repo),
branchName,
remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl })
},
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
}
} finally {
release()
}
}
/**
* Star the Orca repo for the authenticated user.
*/
export async function starOrca(): Promise<boolean> {
await acquire()
try {
await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], {
encoding: 'utf-8'
})
return true
} catch {
return false
} finally {
release()
}
}
/**
* Get the authenticated GitHub viewer when gh is available and logged in.
* Returns null when gh is unavailable, unauthenticated, or the lookup fails.
*/
export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
await acquire()
try {
const { stdout } = await execFileAsync(
'gh',
['api', 'user', '--jq', '{login: .login, email: .email}'],
{ encoding: 'utf-8' }
)
const viewer = JSON.parse(stdout) as { login?: string; email?: string | null }
if (!viewer.login?.trim()) {
return null
}
return {
login: viewer.login.trim(),
email: viewer.email?.trim() || null
}
} catch {
return null
} finally {
release()
}
}
// Why: main-process maps omit repoId because the IPC handler never receives
// a repo identifier beyond path. Exported because runtime consumers receive
// listWorkItems results before the renderer stamps repoId.
export type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
// Why: issue numbers follow creation order within a repository. Pinning this
// sort keeps gh's rich PR rows aligned 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: these fields are intentionally excluded from `gh pr list` because
// statusCheckRollup/review decision/PR-specific merge metadata fan out into
// expensive GraphQL work across every row. Requested reviewers are kept in the
// list payload because the Tasks table renders that column on first paint.
const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify'
function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
id: `issue:${String(item.number)}`,
type: 'issue',
number: Number(item.number),
title: String(item.title ?? ''),
state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
url: String(item.html_url ?? item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
author:
typeof item.user === 'object' && item.user !== null && 'login' in item.user
? String((item.user as { login?: unknown }).login ?? '')
: typeof item.author === 'object' && item.author !== null && 'login' in item.author
? String((item.author as { login?: unknown }).login ?? '')
: null,
...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {})
}
}
function extractHeadOwnerLogin(item: Record<string, unknown>): string | null {
// gh CLI `pr list --json headRepositoryOwner` shape: { login }
if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) {
const login = (item.headRepositoryOwner as { login?: unknown }).login
if (typeof login === 'string' && login.trim()) {
return login
}
}
// REST API `pull_request` shape: head.repo.owner.login
if (typeof item.head === 'object' && item.head !== null) {
const head = item.head as { repo?: unknown; user?: unknown; label?: unknown }
const repo = head.repo
if (typeof repo === 'object' && repo !== null) {
const owner = (repo as { owner?: unknown }).owner
if (typeof owner === 'object' && owner !== null) {
const login = (owner as { login?: unknown }).login
if (typeof login === 'string' && login.trim()) {
return login
}
}
}
// Why: when a fork is deleted or made inaccessible, GitHub can return
// `head.repo = null` but still include `head.user`/`head.label`.
const user = head.user
if (typeof user === 'object' && user !== null) {
const login = (user as { login?: unknown }).login
if (typeof login === 'string' && login.trim()) {
return login
}
}
if (typeof head.label === 'string') {
const owner = head.label.split(':', 1)[0]?.trim()
if (owner) {
return owner
}
}
}
return null
}
function userFromUnknown(
value: unknown
): { login: string; name: string | null; avatarUrl: string } | null {
if (typeof value === 'string') {
const login = value.trim()
return login ? { login, name: null, avatarUrl: '' } : null
}
if (typeof value !== 'object' || value === null) {
return null
}
const raw = value as Record<string, unknown>
const login = typeof raw.login === 'string' ? raw.login.trim() : ''
if (!login) {
return null
}
const databaseId = numberFromUnknown(raw.databaseId)
return {
login,
name: typeof raw.name === 'string' ? raw.name : null,
avatarUrl:
typeof raw.avatarUrl === 'string'
? raw.avatarUrl
: typeof raw.avatar_url === 'string'
? raw.avatar_url
: databaseId !== undefined
? `https://avatars.githubusercontent.com/u/${databaseId}?v=4`
: ''
}
}
function usersFromUnknown(
value: unknown
): { login: string; name: string | null; avatarUrl: string }[] {
if (!Array.isArray(value)) {
return []
}
const users: { login: string; name: string | null; avatarUrl: string }[] = []
for (const entry of value) {
const direct = userFromUnknown(entry)
if (direct) {
users.push(direct)
continue
}
if (typeof entry === 'object' && entry !== null) {
const raw = entry as Record<string, unknown>
const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author)
if (nested) {
users.push(nested)
}
}
}
return users
}
function latestReviewsFromUnknown(value: unknown): NonNullable<GitHubWorkItem['latestReviews']> {
if (!Array.isArray(value)) {
return []
}
const reviews: NonNullable<GitHubWorkItem['latestReviews']> = []
for (const entry of value) {
if (typeof entry !== 'object' || entry === null) {
continue
}
const raw = entry as Record<string, unknown>
const author = userFromUnknown(raw.author)
if (!author) {
continue
}
reviews.push({
login: author.login,
state: typeof raw.state === 'string' ? raw.state : null,
avatarUrl: author.avatarUrl
})
}
return reviews
}
function numberFromUnknown(value: unknown): number | undefined {
const number = typeof value === 'number' ? value : Number(value)
return Number.isFinite(number) ? number : undefined
}
function normalizePRMergeable(value: unknown): PRMergeableState | undefined {
const raw = typeof value === 'string' ? value.toUpperCase() : ''
if (raw === 'MERGEABLE' || raw === 'CONFLICTING' || raw === 'UNKNOWN') {
return raw
}
if (typeof value === 'boolean') {
return value ? 'MERGEABLE' : 'CONFLICTING'
}
return undefined
}
function normalizeReviewDecision(value: unknown): PRReviewDecision | null {
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
? value
: null
}
function isAutoMergeEnabled(value: unknown): boolean {
return typeof value === 'object' && value !== null
}
function checkRollupEntries(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value
}
if (typeof value !== 'object' || value === null) {
return []
}
const raw = value as Record<string, unknown>
const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes
return Array.isArray(nodes) ? nodes : []
}
function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] {
const entries = checkRollupEntries(value)
if (entries.length === 0) {
return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0 }
}
let passed = 0
let failed = 0
let pending = 0
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) {
pending += 1
continue
}
const raw = entry as Record<string, unknown>
const conclusion = String(raw.conclusion ?? raw.state ?? '').toUpperCase()
const status = String(raw.status ?? '').toUpperCase()
if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(conclusion)) {
passed += 1
} else if (
['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(
conclusion
)
) {
failed += 1
} else if (status === 'COMPLETED' && conclusion) {
failed += 1
} else {
pending += 1
}
}
return {
state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success',
total: entries.length,
passed,
failed,
pending
}
}
function mapPullRequestWorkItem(
item: Record<string, unknown>,
baseOwnerRepo: OwnerRepo | null = null
): MainWorkItem {
// Why: fork PRs are disabled in the Start-from picker. We compare the PR head's
// owner to the selected repo's owner; when the base repo is unknown we default
// to false so non-picker call sites see the same shape as before.
const headOwnerLogin = extractHeadOwnerLogin(item)
// Why: only emit isCrossRepository when we actually know the head owner. If
// the gh response lacks `headRepositoryOwner` (older callers, tests without
// that fixture, or gh not returning it), leave the field undefined instead
// of 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 ?? ''),
author:
typeof item.user === 'object' && item.user !== null && 'login' in item.user
? String((item.user as { login?: unknown }).login ?? '')
: typeof item.author === 'object' && item.author !== null && 'login' in item.author
? String((item.author as { login?: unknown }).login ?? '')
: null,
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 } } : {}),
...(additions !== undefined ? { additions } : {}),
...(deletions !== undefined ? { deletions } : {}),
...(changedFiles !== undefined ? { changedFiles } : {}),
...('reviewDecision' in item
? { reviewDecision: normalizeReviewDecision(item.reviewDecision) }
: {}),
...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) }
: {}),
...(item.latestReviews !== undefined
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
: {}),
...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}),
...(item.statusCheckRollup !== undefined
? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) }
: {}),
...(mergeable ? { mergeable } : {}),
...('autoMergeRequest' in item
? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) }
: {}),
...('mergeStateStatus' in item
? {
mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null
}
: {}),
...(typeof item.maintainerCanModify === 'boolean'
? { maintainerCanModify: item.maintainerCanModify }
: {}),
...(isCrossRepository !== null ? { isCrossRepository } : {})
}
}
async function hydrateWorkItemRepositoryMergeMetadata(
items: MainWorkItem[],
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<MainWorkItem[]> {
const hasPullRequest = items.some((item) => item.type === 'pr')
if (!ownerRepo || !hasPullRequest) {
return items
}
// Why: merge method settings are repository-level, so one cached metadata
// probe can keep 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: OwnerRepo | null,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
if (ownerRepo) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
if ('pull_request' in item) {
return null
}
return mapIssueWorkItem(item)
}
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'],
ghOptions
)
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON
// `gh pr view` path fails, still pull review fields from gh so mobile/desktop
// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty.
const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
async function fetchPullRequestReviewFields(
number: number,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
try {
const args = ownerRepo
? [
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_REVIEW_JSON_FIELDS
]
: ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
const { stdout } = await ghExecFileAsync(args, ghOptions)
const item = JSON.parse(stdout) as Record<string, unknown>
return {
...(item.reviewRequests !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests) }
: {}),
...(item.latestReviews !== undefined
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
: {})
}
} catch {
return {}
}
}
async function fetchPullRequestWorkItem(
repoPath: string,
ownerRepo: OwnerRepo | null,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
if (ownerRepo) {
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_DETAIL_JSON_FIELDS
],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
const mapped = mapPullRequestWorkItem(item, ownerRepo)
// Why: merge-metadata GraphQL is best-effort. A failure here must not fall
// through to the REST path below — that path drops latestReviews and blanks
// the mobile/desktop reviewer list for bots that only left a review.
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
try {
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
return {
...mapped,
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
...(mergeMetadata.autoMergeAllowed !== null
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
: {}),
...(mergeMetadata.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
}
} catch {
return mapped
}
} catch {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
const mapped = mapPullRequestWorkItem(
JSON.parse(stdout) as Record<string, unknown>,
ownerRepo
)
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
return { ...mapped, ...reviewFields }
}
}
const { stdout } = await ghExecFileAsync(
['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS],
ghOptions
)
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
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 | null
limit: number
query: ParsedTaskQuery
page: number
}): WorkItemListRequest {
const { kind, ownerRepo, limit, query, page } = args
const searchParts: string[] = []
if (kind === 'issue' && ownerRepo) {
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 used by the Tasks management
// columns. Fetch through gh's rich PR list and slice its 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
]
if (ownerRepo) {
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
out.push('--search', searchParts.join(' '))
return { args: out, offset: (page - 1) * limit }
}
// Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so
// listWorkItems can lift per-side errors into the IPC envelope. The issue-side
// error is the specific new class of silent wrongness introduced by #1076 —
// PR-side errors existed before and are explicitly out of scope for this
// feature per the parent design doc §6.
type PartialWorkItemsResult = {
items: MainWorkItem[]
issuesError?: 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 gh cannot use cwd to infer repo
// context. Without a resolved owner/repo, running gh would query local state.
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
type ResolvedPrWorkItemSource = {
source: OwnerRepo | null
originCandidate: OwnerRepo | null
upstreamCandidate: OwnerRepo | null
}
async function resolvePrWorkItemSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ResolvedPrWorkItemSource> {
const [originCandidate, upstreamCandidate] = await Promise.all([
getOwnerRepo(repoPath, connectionId, localGitOptions),
getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
])
const source =
preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate
return { source, originCandidate, upstreamCandidate }
}
/**
* gh exec for calls that rely on gh's own cwd→repo resolution (no explicit
* owner/repo). Serves a remembered deterministic resolution failure without
* spawning, so a remote-less repo costs one gh spawn per config change/TTL
* instead of two per Tasks refresh.
*/
async function ghCwdResolvedExec(
context: GitHubRepoContext,
args: string[],
ghOptions: GhExecOptions
): Promise<{ stdout: string; stderr: string }> {
const remembered = await getRememberedGhCwdResolutionFailure(context)
if (remembered !== null) {
throw new Error(remembered)
}
try {
return await ghExecFileAsync(args, ghOptions)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (isGhCwdRepoResolutionFailure(message)) {
await rememberGhCwdResolutionFailure(context, message)
}
throw err
}
}
async function listRecentWorkItems(
repoPath: string,
issueOwnerRepo: OwnerRepo | null,
prOwnerRepo: OwnerRepo | null,
limit: number,
page: number,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<PartialWorkItemsResult> {
const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(repoContext)
const requiresExplicitRepo = Boolean(connectionId)
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
const recentQuery = parseTaskQuery('is:open')
const issueRequest = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query: recentQuery,
page
})
const prRequest = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query: recentQuery,
page
})
if (noCache) {
issueRequest.args.splice(1, 2)
}
if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) {
// Why: allSettled so a 403 on upstream issues doesn't zero out the origin
// PR half — the UI renders partial results plus a banner for the failing
// side, matching the parent design doc's partial-failure rule (§2).
const [issuesSettled, prsSettled] = await Promise.allSettled([
issueOwnerRepo
? ghExecFileAsync(issueRequest.args, ghOptions)
: requiresExplicitRepo
? Promise.resolve({ stdout: '[]' })
: ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
prOwnerRepo
? ghExecFileAsync(prRequest.args, ghOptions)
: requiresExplicitRepo
? Promise.resolve({ stdout: '[]' })
: ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
])
let issues: MainWorkItem[] = []
let issuesError: ClassifiedError | undefined
if (issuesSettled.status === 'fulfilled') {
issues = (JSON.parse(issuesSettled.value.stdout) as Record<string, unknown>[])
// Why: the GitHub search/issues endpoint may still return PRs with a
// pull_request marker even when queried with is:issue. Filter them out
// here to keep the issue and PR buckets clean.
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
} else {
const stderr =
issuesSettled.reason instanceof Error
? issuesSettled.reason.message
: String(issuesSettled.reason)
issuesError = classifyListIssuesError(stderr)
}
let prs: MainWorkItem[] = []
if (prsSettled.status === 'fulfilled') {
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[])
.slice(prRequest.offset, prRequest.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions)
} else {
// Why: PR-side failures must preserve the pre-diff behavior of
// Promise.all by re-throwing so the rejection propagates up through
// listWorkItems to the renderer's cross-repo aggregator (which counts
// the repo as failed). This feature is scoped to the issue-side silent
// wrongness from #1076; PR errors must not be silently swallowed here.
// Why: if the issue side ALSO failed, the classified issuesError would
// otherwise be silently dropped when we throw the PR reason. Log it so
// debugging both-sides-failed scenarios (e.g. 403 on both endpoints)
// isn't blind to the issue-side classification.
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
}
}
// Why: the fallback path (non-GitHub remote — neither issueOwnerRepo nor
// prOwnerRepo resolved) intentionally stays on Promise.all rather than the
// Promise.allSettled + per-side classification used above. There are no
// `sources` to surface on this branch and nothing for the partial-failure
// banner to render, so a single-side failure here means the whole call is
// effectively unusable for the feature — reject-all matches reality. If
// non-GitHub remotes ever grow source metadata, revisit this symmetry.
const [issuesResult, prsResult] = await Promise.all([
ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
])
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[])
.slice(prRequest.offset, prRequest.offset + limit)
.map((item) => mapPullRequestWorkItem(item, null))
return {
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit)
}
}
async function listQueriedWorkItems(
repoPath: string,
issueOwnerRepo: OwnerRepo | null,
prOwnerRepo: OwnerRepo | null,
query: ParsedTaskQuery,
limit: number,
page?: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PartialWorkItemsResult> {
const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(repoContext)
const requiresExplicitRepo = Boolean(connectionId)
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'
// Why: run the issue and PR fetches in parallel but surface the
// issue-side error separately so the IPC envelope can carry it up. PR-side
// failures retain the prior swallow-and-log behavior per parent doc §6.
const issueFetch = (async (): Promise<PartialWorkItemsResult> => {
if (!issueScope) {
return { items: [] }
}
if (requiresExplicitRepo && !issueOwnerRepo) {
return { items: [] }
}
const request = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query,
page: page ?? 1
})
try {
const { stdout } = issueOwnerRepo
? await ghExecFileAsync(request.args, ghOptions)
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
return {
items: (JSON.parse(stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { items: [], issuesError: classifyListIssuesError(stderr) }
}
})()
const prFetch = (async (): Promise<MainWorkItem[]> => {
if (!prScope) {
return []
}
if (requiresExplicitRepo && !prOwnerRepo) {
return []
}
const request = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query,
page: page ?? 1
})
try {
const { stdout } = prOwnerRepo
? await ghExecFileAsync(request.args, ghOptions)
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
const mapped = (JSON.parse(stdout) as Record<string, unknown>[])
.slice(request.offset, request.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions)
if (query.state === 'closed') {
return hydrated.filter((item) => item.state !== 'merged')
}
return hydrated
} catch (err) {
console.warn('listQueriedWorkItems PRs partial failure:', err)
return []
}
})()
const [issueResult, prItems] = await Promise.all([issueFetch, prFetch])
return {
items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit),
issuesError: issueResult.issuesError
}
}
export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string,
page?: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListWorkItemsResult<MainWorkItem>> {
const trimmedQuery = query?.trim() ?? ''
const requestedPage = normalizeWorkItemPage(page)
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return {
items: [],
sources: {
issues: null,
prs: null,
originCandidate: null,
upstreamCandidate: null
}
}
}
const [issueResolved, prResolved] = await Promise.all([
resolveIssueSource(repoPath, preference, connectionId, localGitOptions),
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
])
const issueOwnerRepo = issueResolved.source
const prOwnerRepo = prResolved.source
await acquire()
try {
// Why: errors propagate to IPC so the renderer's cross-repo aggregator can
// count this repo as failed and surface the partial-failure banner. A
// catch-all here would make an auth/network failure indistinguishable from
// an empty result and silently 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 ? { issues: partial.issuesError } : 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 the
// "Closed" filter actually means closed-without-merge.
parts.push('is:closed')
if (query.scope !== 'issue') {
parts.push('-is:merged')
}
} else if (query.state === 'merged') {
parts.push('is:merged')
}
if (query.draft) {
parts.push('draft:true')
}
if (query.assignee) {
parts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
}
if (query.author) {
parts.push(`author:${quoteGitHubSearchValue(query.author)}`)
}
if (query.reviewRequested) {
parts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
}
if (query.reviewedBy) {
parts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
}
for (const label of query.labels) {
parts.push(`label:${quoteGitHubSearchValue(label)}`)
}
if (query.freeText) {
parts.push(query.freeText)
}
return parts.join(' ')
}
function quoteGitHubSearchValue(value: string): string {
return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
}
async function countWorkItemsForQuery(
repoPath: string,
ownerRepo: OwnerRepo,
query: ParsedTaskQuery,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<number> {
const searchQ = buildSearchQueryString(ownerRepo, query)
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`,
'--jq',
'.total_count'
],
ghOptions
)
// Why: over-counts gh cache hits, which is the safe direction — the search
// bucket is only 30/min and the next probe corrects the estimate.
noteRateLimitSpend('search')
return Number.parseInt(stdout.trim(), 10) || 0
}
function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean {
// Why: GitHub treats owner and repo names as case-insensitive, so remotes
// with different casing (StablyAI/Orca vs stablyai/orca) point at the same
// repo and should not split into two search queries.
return (
left?.owner.toLowerCase() === right?.owner.toLowerCase() &&
left?.repo.toLowerCase() === right?.repo.toLowerCase()
)
}
function defaultOpenWorkItemQuery(): ParsedTaskQuery {
return {
scope: 'all',
state: 'open',
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
}
// Why: uses GitHub's search API to get total_count without fetching items.
// This powers the pagination bar so the user sees total pages upfront.
// Cached for 120s to avoid burning the search rate limit (30 req/min).
export async function countWorkItems(
repoPath: string,
query?: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<number> {
const trimmedQuery = query?.trim() ?? ''
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return 0
}
const [issueResolved, prResolved] = await Promise.all([
resolveIssueSource(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()
// Why: counts are decorative (pagination totals). The search bucket is only
// 30/min, so a multi-repo Tasks page must stop counting when the budget is
// gone instead of converting the remaining repos into 403 spawns. getRateLimit
// is 30s-cached and single-flight, so priming here is one spawn per window.
await getRateLimit()
if (rateLimitGuard('search').blocked) {
return 0
}
await acquire()
try {
if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) {
return await countWorkItemsForQuery(
repoPath,
ownerRepo,
effectiveQuery,
connectionId,
localGitOptions
)
}
const counts: Promise<number>[] = []
// Why: `draft`, `reviewRequested`, and `reviewedBy` are PR-only predicates.
// When present, the issue half would always return 0 and wastes a search
// API call — skip the issue half entirely in that case.
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 a single failing search (e.g. transient network, rate
// limit on one side) doesn't silently zero out the total; sum only the
// fulfilled halves instead.
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<{ owner: string; repo: string } | null> {
return getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))
}
/**
* Resolve a fork's upstream/parent owner/repo, or null when the repo is not a
* fork. Why: a fork's `origin` points at the personal copy, so repo identity
* (notably the avatar) should prefer the upstream. Fast-paths the `upstream`
* remote (offline); otherwise asks the GitHub API for the fork parent. The API
* call targets the explicit origin slug, so it works for SSH repos too.
* Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null.
*/
export async function getRepoUpstream(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<OwnerRepo | null> {
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const origin = await getOwnerRepo(repoPath, connectionId, ...localGitArgs)
if (!origin) {
return null
}
const upstreamRemote = await getOwnerRepoForRemote(
repoPath,
'upstream',
connectionId,
...localGitArgs
)
if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) {
return upstreamRemote
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'],
// Why: best-effort fork lookup runs at add-time; cap latency so a stalled
// gh process can't hold up repo creation.
{
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
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
return data.isFork && owner && repo ? { owner, repo } : 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: gh prints the PR URL (not JSON) here; match any host, not just
// github.com, so a GitHub Enterprise Server URL still parses directly (#8312).
const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/)
if (!urlMatch) {
return null
}
return { number: Number(urlMatch[1]), url: urlMatch[0] }
}
// Why: `gh --repo OWNER/REPO` resolves the shorthand against gh's default host
// (usually github.com), not the repo's remote — so a GHES repo would target a
// same-named github.com repo, or fail. Qualify with the host for GHES so gh hits
// the Enterprise server; this is the only host signal for SSH repos, which run
// gh with no cwd context (#8312). github.com keeps the bare shorthand.
function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string {
return slug.host && slug.host.toLowerCase() !== 'github.com'
? `${slug.host}/${slug.owner}/${slug.repo}`
: `${slug.owner}/${slug.repo}`
}
async function findOpenPRByHeadBase(args: {
repoPath: string
repoArg: string
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.repoArg,
'--head',
args.head,
'--base',
args.base,
'--state',
'open',
'--limit',
'2',
'--json',
'number,url'
],
{
...ghRepoExecOptions(context),
...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options))
}
)
const list = JSON.parse(stdout) as { number?: number; url?: string }[]
if (list.length !== 1 || !list[0]?.number || !list[0]?.url) {
return null
}
return { number: list[0].number, url: list[0].url }
}
async function readPullRequestTemplate(
repoPath: string,
connectionId?: string | null
): Promise<string> {
const relativeCandidates = [
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'pull_request_template.md',
'PULL_REQUEST_TEMPLATE.md',
'docs/pull_request_template.md',
'docs/PULL_REQUEST_TEMPLATE.md'
]
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
if (connectionId && !remoteProvider) {
return ''
}
for (const relativeCandidate of relativeCandidates) {
try {
if (remoteProvider) {
const result = await remoteProvider.readFile(
joinWorktreeRelativePath(repoPath, relativeCandidate)
)
if (result.isBinary) {
continue
}
return result.content
}
return await readFile(join(repoPath, relativeCandidate), 'utf8')
} catch {
// Try the next conventional PR template path.
}
}
return ''
}
export async function createGitHubPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'github') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
// Why: github.com-only slug parsing returns null for GHES, so fall back to the
// enterprise resolver (gh-authenticated custom host) before giving up (#8312).
const ownerRepo =
(await getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))) ??
(await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options))
if (!ownerRepo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires a GitHub remote.'
}
}
// Host-qualified for GHES so gh targets the Enterprise server, not github.com.
const repoArg = ghRepoArg(ownerRepo)
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)),
timeout: 60_000,
idempotent: false
})
const created = parseCreatePRPayload(stdout)
if (created) {
return { ok: true, ...created }
}
const found = head
? await findOpenPRByHeadBase({
repoPath,
repoArg,
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,
repoArg,
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 = {}
): Promise<MainWorkItem | null> {
await acquire()
try {
if (type === 'issue') {
return await fetchIssueWorkItem(
repoPath,
await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
number,
connectionId,
localGitOptions
)
}
if (type === 'pr') {
return await fetchPullRequestWorkItem(
repoPath,
await getOwnerRepo(repoPath, connectionId, localGitOptions),
number,
connectionId,
localGitOptions
)
}
try {
const issue = await fetchIssueWorkItem(
repoPath,
await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
number,
connectionId,
localGitOptions
)
if (issue) {
return issue
}
} catch (err) {
// Why: the issue lookup now targets `upstream` while the PR lookup targets `origin`,
// so a transient upstream failure (5xx, rate limit, network flake) on issue #N would
// silently fall through to origin's PR #N — potentially a completely unrelated item.
// Only fall through when the issue genuinely doesn't exist (404); re-throw everything
// else so the outer catch returns null and the caller sees a real failure instead of
// a wrong item. classifyGhError centralizes the 404/"not found" pattern-matching.
const stderr = err instanceof Error ? err.message : String(err)
if (classifyGhError(stderr).type !== 'not_found') {
throw err
}
}
return await fetchPullRequestWorkItem(
repoPath,
await getOwnerRepo(repoPath, connectionId, localGitOptions),
number,
connectionId,
localGitOptions
)
} catch {
return null
} finally {
release()
}
}
export async function getWorkItemByOwnerRepo(
repoPath: string,
ownerRepo: OwnerRepo,
number: number,
type: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
await acquire()
try {
if (type === 'issue') {
return await fetchIssueWorkItem(repoPath, ownerRepo, number, connectionId, localGitOptions)
}
return await fetchPullRequestWorkItem(
repoPath,
ownerRepo,
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
}
type RestPullRequest = {
number: number
title: string
state: string
html_url?: string
url?: string
updated_at?: string
draft?: boolean
merged_at?: string | null
mergeable?: boolean | null
mergeable_state?: string | null
base?: { ref?: string; sha?: string }
head?: { ref?: string; sha?: string }
}
const PR_LOOKUP_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_BRANCH_LIST_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
const GITHUB_AUTO_MERGE_METHODS: Record<GitHubPRMergeMethod, 'MERGE' | 'SQUASH' | 'REBASE'> = {
merge: 'MERGE',
squash: 'SQUASH',
rebase: 'REBASE'
}
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
acceptMergedFallbackPR?: boolean
// Why: compare merged implicit PRs against the inspected worktree HEAD, not
// the main repo HEAD, without adding 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 {
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
}
}
function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean {
// Why: merged PRs are historical branch matches unless the worktree has an
// explicit PR link. Showing them as implicit review context leaves nothing
// meaningful to unlink after the branch has been rebased or merged.
return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged'
}
async function getCurrentHeadOid(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<string | null> {
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
? await provider.exec(['rev-parse', 'HEAD'], repoPath)
: await gitExecFileAsync(['rev-parse', 'HEAD'], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return result.stdout.trim() || null
} catch {
return null
}
}
function shouldHideMergedImplicitPR(
data: PullRequestLookupData | null,
linkedPRNumber: number | null | undefined,
currentHeadOid: string | null
): boolean {
if (!data || !isMergedImplicitPR(data, linkedPRNumber)) {
return false
}
// Why: keep hiding historical merged branch matches, but preserve the merged
// PR for the exact commit currently checked out in the sidebar.
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
// per-session cache bounded even if many short-lived branches are inspected.
repositoryMergeMetadataCache.delete(cacheKey)
repositoryMergeMetadataCache.set(cacheKey, {
value,
expiresAt: now + ttlMs
})
pruneRepositoryMergeMetadataCache(now)
}
async function detectRepositoryMergeMetadata(
ownerRepo: OwnerRepo,
branchName: string | undefined,
ghOptions: GhExecOptions
): Promise<GitHubRepositoryMergeMetadata> {
const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${
branchName ?? '__repo__'
}`
pruneRepositoryMergeMetadataCache()
const cached = repositoryMergeMetadataCache.get(cacheKey)
if (cached) {
return cached.value
}
const guard = rateLimitGuard('graphql')
if (guard.blocked) {
return { mergeQueueRequired: null, autoMergeAllowed: null }
}
const query = branchName
? `query($owner: String!, $repo: String!, $branch: String!) {
repository(owner: $owner, name: $repo) {
viewerDefaultMergeMethod
mergeCommitAllowed
rebaseMergeAllowed
squashMergeAllowed
autoMergeAllowed
mergeQueue(branch: $branch) { id }
}
}`
: `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
viewerDefaultMergeMethod
mergeCommitAllowed
rebaseMergeAllowed
squashMergeAllowed
autoMergeAllowed
}
}`
try {
noteRateLimitSpend('graphql')
const args = [
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`
]
if (branchName) {
args.push('-f', `branch=${branchName}`)
}
const { stdout } = await ghExecFileAsync(args, ghOptions)
const parsed = JSON.parse(stdout) as {
data?: {
repository?: {
viewerDefaultMergeMethod?: unknown
mergeCommitAllowed?: unknown
rebaseMergeAllowed?: unknown
squashMergeAllowed?: unknown
autoMergeAllowed?: unknown
mergeQueue?: { id?: unknown } | 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) : null,
autoMergeAllowed:
typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null,
...(mergeMethodSettings ? { mergeMethodSettings } : {})
}
cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS)
return value
} catch {
// Why: failed merge-queue probes should stay conservative without
// retrying GraphQL on every status 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
): Promise<PullRequestLookupData> {
const normalized = normalizePullRequestLookupData(data)
const hasRichMergeFields =
'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
const mergeMetadata = hasRichMergeFields
? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions)
: 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
): Promise<PullRequestLookupData | null> {
if (!branchData) {
return null
}
try {
return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData
} catch {
return branchData
}
}
async function getRestPRForBranch(
prRepo: OwnerRepo,
headOwner: string,
branchName: string,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
const head = encodeURIComponent(`${headOwner}:${branchName}`)
const { stdout } = await ghExecFileAsync(
['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`],
ghOptions
)
const list = JSON.parse(stdout) as RestPullRequest[]
const pr = list[0]
return pr ? mapRestPullRequest(pr) : null
}
async function getFallbackPRListForBranch(
prRepo: OwnerRepo,
branchName: string,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
const { stdout } = await ghExecFileAsync(
[
'pr',
'list',
'--repo',
`${prRepo.owner}/${prRepo.repo}`,
'--head',
branchName,
'--state',
'all',
'--limit',
'1',
'--json',
PR_BRANCH_LIST_JSON_FIELDS
],
ghOptions
)
const list = JSON.parse(stdout) as PullRequestLookupData[]
return list[0] ?? null
}
type TrackedUpstreamBranch = {
remoteName: string
branchName: string
}
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512
type TrackedUpstreamSnapshotCacheEntry = {
expiresAt: number
gitConfigSignature?: string
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}
type TrackedUpstreamSnapshotProbeResult = {
cacheable: boolean
gitConfigSignature?: string
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}
const trackedUpstreamSnapshotCache = new Map<string, TrackedUpstreamSnapshotCacheEntry>()
const trackedUpstreamSnapshotInFlight = new Map<
string,
Promise<TrackedUpstreamSnapshotProbeResult>
>()
const trackedUpstreamSnapshotGenerations = new Map<string, symbol>()
function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol {
const generation = Symbol()
trackedUpstreamSnapshotGenerations.set(cacheKey, generation)
return generation
}
function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void {
// Why: generations only guard an active probe; retaining completed repo keys
// leaks worktree/runtime identities after the short-lived snapshot TTL expires.
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 is not 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 prOwnerRepoKey(ownerRepo: OwnerRepo): string {
return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}`
}
function shouldRetryTrackedUpstreamBranch(
upstreamBranch: TrackedUpstreamBranch,
branchName: string,
upstreamHeadRepo: OwnerRepo,
headRepo: OwnerRepo | null
): boolean {
if (upstreamBranch.branchName !== branchName) {
return true
}
if (!headRepo) {
return true
}
return prOwnerRepoKey(upstreamHeadRepo) !== prOwnerRepoKey(headRepo)
}
async function getTrackedUpstreamBranch(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamBranch | null> {
const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions)
const now = Date.now()
const cached = trackedUpstreamSnapshotCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch(
cached,
repoPath,
connectionId,
localGitOptions
)
if (
configSignatureMatches &&
cached.upstreamsByBranchName.has(branchName) &&
canUseCachedTrackedUpstreamBranch(cached, branchName)
) {
return cached.upstreamsByBranchName.get(branchName) ?? null
}
trackedUpstreamSnapshotCache.delete(cacheKey)
}
if (cached) {
trackedUpstreamSnapshotCache.delete(cacheKey)
}
const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
if (inFlight) {
const result = await inFlight
if (result.upstreamsByBranchName.has(branchName)) {
return result.upstreamsByBranchName.get(branchName) ?? null
}
// Why: a concurrent snapshot may finish before this branch exists in git.
// Re-probe instead of returning a one-shot synthetic null.
const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
if (retryInFlight) {
const retryResult = await retryInFlight
return retryResult.upstreamsByBranchName.get(branchName) ?? null
}
}
// Why: PR polling can ask about hundreds of local worktree branches at once.
// Read the branch upstream snapshot in one git process per repo/runtime
// instead of spawning one failing `branch@{upstream}` probe per branch.
const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey)
const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions)
trackedUpstreamSnapshotInFlight.set(cacheKey, probe)
try {
const result = await probe
if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) {
trackedUpstreamSnapshotCache.set(cacheKey, {
...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}),
upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName),
expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS
})
pruneTrackedUpstreamSnapshotCache(Date.now())
}
if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) {
const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey)
if (fresherCached?.upstreamsByBranchName.has(branchName)) {
return fresherCached.upstreamsByBranchName.get(branchName) ?? null
}
}
return result.upstreamsByBranchName.get(branchName) ?? null
} finally {
if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) {
trackedUpstreamSnapshotInFlight.delete(cacheKey)
}
finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration)
}
}
async function probeTrackedUpstreamSnapshot(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamSnapshotProbeResult> {
const startingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches(
repoPath,
connectionId,
localGitOptions
)
const endingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro
const configSignatureChanged =
isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature
const gitConfigSignature =
startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined
return {
// Why: transient git failures must not cache an empty snapshot that forces
// every branch lookup to delete and re-probe on the next refresh tick.
cacheable: !configSignatureChanged && !probeFailed,
probeFailed,
...(gitConfigSignature ? { gitConfigSignature } : {}),
upstreamsByBranchName
}
}
function getCacheableTrackedUpstreamSnapshot(
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
): Map<string, TrackedUpstreamBranch | null> {
// Why: SSH/WSL cannot cheaply inspect remote .git/config here. The short TTL
// still bounds stale positives, while repeated PR refreshes share one scan.
return upstreamsByBranchName
}
function canUseCachedTrackedUpstreamBranch(
cached: TrackedUpstreamSnapshotCacheEntry,
branchName: string
): boolean {
return cached.upstreamsByBranchName.has(branchName)
}
async function doesTrackedUpstreamCacheConfigSignatureMatch(
cached: TrackedUpstreamSnapshotCacheEntry,
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
if (!cached.gitConfigSignature) {
return true
}
const currentSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
return currentSignature === cached.gitConfigSignature
}
function getTrackedUpstreamBranchCacheKey(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return [runtimeKey, repoPath].join('\0')
}
async function probeTrackedUpstreamBranches(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<{
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}> {
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
? await provider.exec(args, repoPath)
: await gitExecFileAsync(args, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return {
probeFailed: false,
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
}
} catch {
return { probeFailed: true, upstreamsByBranchName: new Map() }
}
}
function parseTrackedUpstreamBranches(stdout: string): Map<string, TrackedUpstreamBranch | null> {
const upstreamsByBranchName = new Map<string, TrackedUpstreamBranch | null>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const [branchName, upstreamRef] = line.split('\0')
const localBranchName = branchName?.replace(/^refs\/heads\//, '')
if (!localBranchName) {
continue
}
upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? ''))
}
return upstreamsByBranchName
}
function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null {
const remoteRefPrefix = 'refs/remotes/'
const normalizedRef = upstreamRef.trim()
if (normalizedRef.startsWith(remoteRefPrefix)) {
return parseTrackedUpstreamBranch(normalizedRef.slice(remoteRefPrefix.length))
}
if (normalizedRef.startsWith('refs/heads/')) {
return null
}
return parseTrackedUpstreamBranch(normalizedRef)
}
async function lookupPRByBranchName(args: {
candidates: OwnerRepo[]
headRepo: OwnerRepo | null
branchName: string
ghOptions: GhExecOptions
}): 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)
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)
if (data) {
return { data, dataRepo: candidate }
}
} catch (retryErr) {
if (!hasPendingError) {
pendingError = retryErr
hasPendingError = true
}
}
}
}
// Why: branch-list failures are ambiguous for fork discovery, but exact
// fallback-number recovery should still get a chance before surfacing error.
return hasPendingError
? { data: null, dataRepo: null, pendingError }
: { data: null, dataRepo: null }
}
try {
const { stdout } = await ghExecFileAsync(
['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS],
args.ghOptions
)
return {
data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
dataRepo: null
}
} catch (err) {
if (isNoPullRequestError(err)) {
return { data: null, dataRepo: null }
}
throw err
}
}
async function getRestPRByNumber(
ownerRepo: OwnerRepo,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest)
}
async function getPRByNumber(
ownerRepo: OwnerRepo,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
PR_LOOKUP_JSON_FIELDS
],
ghOptions
)
return hydratePullRequestLookupData(
ownerRepo,
JSON.parse(stdout) as PullRequestLookupData,
ghOptions
)
} catch (err) {
// Why: deleted or manually edited linked PR metadata should fall back to
// branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
if (isNotFoundGhError(err)) {
return null
}
try {
const restData = await getRestPRByNumber(ownerRepo, number, ghOptions)
return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null
} catch (restErr) {
if (isNotFoundGhError(restErr)) {
return null
}
if (!shouldStopAfterExactLookupError(restErr)) {
return null
}
throw restErr
}
}
}
async function lookupPRByNumber(args: {
candidates: OwnerRepo[]
number: number
ghOptions: ReturnType<typeof ghRepoExecOptions>
}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> {
for (const candidate of args.candidates) {
try {
const linkedData = await getPRByNumber(candidate, args.number, args.ghOptions)
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 should not turn every poll into an
// error when the PR was deleted or belonged to a different repo.
return { data: null, dataRepo: null }
}
throw err
}
}
function isNotFoundGhError(err: unknown): boolean {
const stderr = err instanceof Error ? err.message : String(err)
return classifyGhError(stderr).type === 'not_found'
}
function shouldStopAfterExactLookupError(err: unknown): boolean {
const stderr = err instanceof Error ? err.message : String(err)
const type = classifyGhError(stderr).type
return type !== 'not_found'
}
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.
*
* When `linkedPRNumber` is provided, it is the source of truth. This handles
* "create from PR" worktrees whose local branch differs from the PR head ref,
* and prevents a coalesced linked-PR refresh from fanning out an unrelated
* branch lookup result to sibling aliases.
* `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is
* used only after branch lookup misses.
*/
export async function getPRForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
fallbackPRNumber?: number | null,
options: GitHubPRBranchLookupOptions = {}
): Promise<PRInfo | null> {
const outcome = await getPRForBranchOutcome(
repoPath,
branch,
linkedPRNumber,
connectionId,
fallbackPRNumber,
options
)
return outcome.kind === 'found' ? outcome.pr : null
}
// Why: the exact-linked fallback (`gh pr view` with no resolved repo candidates)
// returns dataRepo=null, which would leave the merged-PR membership probe unable
// to run. Derive the PR's own repo from its web URL so a diverged merged linked
// PR can still be confirmed and cleared. Host-agnostic to cover GitHub Enterprise.
function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null {
const match = url.match(/^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/)
return match ? { owner: match[1], repo: match[2] } : null
}
export async function getPRForBranchOutcome(
repoPath: string,
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
fallbackPRNumber?: number | null,
options: GitHubPRBranchLookupOptions = {}
): Promise<PRRefreshOutcome> {
// Strip refs/heads/ prefix if present
const branchName = branch.replace(/^refs\/heads\//, '')
// Why: detached HEAD cannot use branch lookup, but an exact linked/fallback
// PR number remains 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)
await acquire()
try {
const { candidates, headRepo } = await resolvePRRepositoryCandidates(
repoPath,
connectionId,
...localGitArgs
)
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
const explicitCurrentHeadOid =
typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0
? options.currentHeadOid.trim()
: null
let confirmedContainedHeadOid: string | null = null
let headDivergedFromMergedPRAtOid: string | null = null
const mergedPRContainsHead = async (
candidate: PullRequestLookupData,
candidateRepo: OwnerRepo | null,
headOid: string | null
): Promise<MergedPRCommitMembership> => {
if (!candidateRepo || !headOid) {
return 'unknown'
}
const membership = await isCommitPartOfMergedPR({
ownerRepo: candidateRepo,
prNumber: candidate.number,
commitOid: headOid,
ghOptions
})
if (membership === 'contained') {
confirmedContainedHeadOid = headOid
}
return membership
}
const recordLinkedMergedPRDivergence = async (
candidate: PullRequestLookupData | null,
candidateRepo: OwnerRepo | null
): Promise<void> => {
if (
typeof linkedPRNumber !== 'number' ||
!candidate ||
mapPRState(candidate.state, candidate.isDraft) !== 'merged' ||
explicitCurrentHeadOid === null ||
candidate.headRefOid === explicitCurrentHeadOid
) {
return
}
const membership = await mergedPRContainsHead(
candidate,
candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url),
explicitCurrentHeadOid
)
if (membership === 'not-contained') {
// explicitCurrentHeadOid is non-null here (guarded above); record the
// exact head so consumers only clear the worktree that actually diverged.
headDivergedFromMergedPRAtOid = explicitCurrentHeadOid
}
}
const hideMergedImplicitPR = async (
candidate: PullRequestLookupData | null,
candidateRepo: OwnerRepo | null
) => {
if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) {
return false
}
// Why: prefer the caller-supplied worktree HEAD; only shell out (against
// the main repo path) when no explicit oid is available, matching legacy
// behavior. This keeps 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 worktree can sit behind its own PR's final head (update-branch
// merges, web-committed suggestions). A head that is one of the PR's own
// commits is the same line of work, not a reused branch name — keep the
// merged PR visible instead of offering "create a pull request".
return (
(await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !==
'contained'
)
}
if (typeof linkedPRNumber === 'number') {
const exactLookup = await lookupPRByNumber({
candidates,
number: linkedPRNumber,
ghOptions
})
data = exactLookup.data
dataRepo = exactLookup.dataRepo
} else if (branchName) {
// During a rebase the worktree is in detached HEAD and branch is empty.
// An empty --head filter causes gh to return an arbitrary PR.
const branchLookup = await lookupPRByBranchName({
candidates,
headRepo,
branchName,
ghOptions
})
data = branchLookup.data
dataRepo = branchLookup.dataRepo
if ('pendingError' in branchLookup) {
pendingBranchLookupError = branchLookup.pendingError
hasPendingBranchLookupError = true
}
if (!data) {
// Why: the tracked upstream can identify the real PR head by branch
// name or by fork owner even when branch names match locally.
const upstreamBranch = await getTrackedUpstreamBranch(
repoPath,
branchName,
connectionId,
localGitOptions
)
if (upstreamBranch) {
const upstreamHeadRepo =
(await getOwnerRepoForRemote(
repoPath,
upstreamBranch.remoteName,
connectionId,
...localGitArgs
)) ?? headRepo
if (
upstreamHeadRepo &&
shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo)
) {
const upstreamLookup = await lookupPRByBranchName({
candidates,
headRepo: upstreamHeadRepo,
branchName: upstreamBranch.branchName,
ghOptions
})
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') {
const fallbackLookup = await lookupPRByNumber({
candidates,
number: fallbackPRNumber,
ghOptions
})
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 on preservation: fallback numbers come from
// callers that already gated them on head equality or confirmed
// containment; re-hiding against the main-repo HEAD would blank
// deleted-head merged PRs that are deliberately kept visible.
const shouldPreserveMergedFallback =
!explicitHeadHidesMergedImplicitPR &&
(fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true)
// Why: a currently visible PR can be merged outside Orca; when the caller
// marks the fallback as visible review state, keep its lifecycle fresh even
// if GitHub no longer reports it by branch (for example deleted heads).
if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
const mergeable = derivePullRequestMergeable(data)
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 } : {}),
...(data.mergeQueueRequired !== undefined
? { mergeQueueRequired: data.mergeQueueRequired }
: {}),
...(data.mergeMethodSettings !== undefined
? { mergeMethodSettings: data.mergeMethodSettings }
: {}),
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
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: OwnerRepo,
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; use the suite page
// as the actionable destination when GraphQL exposes one.
url:
nullableString(suite.url) ??
(headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null)
}
}
function mapGraphQLPRChecksResponse(
ownerRepo: OwnerRepo,
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
// inside the same rollup as check runs. Keep check-run metadata on 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: OwnerRepo,
headSha: string | undefined,
ghOptions: GhExecOptions,
noCache?: boolean
): Promise<PRCheckDetail[] | null> {
if (!headSha) {
return null
}
try {
await assertRateLimitBudget('core')
} 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
)
noteRateLimitSpend('core')
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
)
noteRateLimitSpend('core')
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: the REST fallback is already degraded; keep richer check-run rows
// if the 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
)
noteRateLimitSpend('core')
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?: OwnerRepo | null,
options?: { noCache?: boolean },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRCheckDetail[]> {
void headSha
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
const fallbackToPRChecks = async (): Promise<PRCheckDetail[]> => {
await assertRateLimitBudget('graphql')
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 genuinely has no check
// runs yet. Treat that as an empty optional section, not a load failure.
if (stderr.toLowerCase().includes('no checks reported')) {
return { stdout: '[]', stderr }
}
throw err
})
noteRateLimitSpend('graphql')
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')
} 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, but when the
// user explicitly clicks refresh we must skip it so gh fetches 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
)
noteRateLimitSpend('graphql')
const checks = mapGraphQLPRChecksResponse(
ownerRepo,
JSON.parse(stdout) as GraphQLPRChecksResponse
)
if (checks !== null) {
return checks
}
} catch (err) {
// Why: if GitHub's richer rollup query is unavailable, the older gh
// command still returns the combined check/status list for display.
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: OwnerRepo,
headSha: string,
suiteId: number | null | undefined
): string {
const base = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks`
return typeof suiteId === 'number' && Number.isFinite(suiteId)
? `${base}#check-suite-${suiteId}`
: base
}
function nullableString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function nullableNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] {
if (!Array.isArray(raw)) {
return []
}
return raw
.filter((annotation): annotation is Record<string, unknown> => Boolean(annotation))
.map((annotation) => ({
path: nullableString(annotation.path),
startLine: nullableNumber(annotation.start_line),
endLine: nullableNumber(annotation.end_line),
annotationLevel: nullableString(annotation.annotation_level),
title: nullableString(annotation.title),
message: nullableString(annotation.message) ?? '',
rawDetails: nullableString(annotation.raw_details)
}))
}
function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] {
if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) {
return []
}
const jobs = (raw as { jobs: unknown[] }).jobs
.filter((job): job is Record<string, unknown> => Boolean(job))
.map((job) => ({
id: nullableNumber(job.id),
name: nullableString(job.name) ?? 'Unnamed job',
status: nullableString(job.status),
conclusion: nullableString(job.conclusion),
startedAt: nullableString(job.started_at),
completedAt: nullableString(job.completed_at),
url: nullableString(job.html_url),
logTail: null,
steps: Array.isArray(job.steps)
? job.steps
.filter((step): step is Record<string, unknown> => Boolean(step))
.map((step) => ({
name: nullableString(step.name) ?? 'Unnamed step',
status: nullableString(step.status),
conclusion: nullableString(step.conclusion),
startedAt: nullableString(step.started_at),
completedAt: nullableString(step.completed_at)
}))
: []
}))
const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : []
return exactMatches.length > 0 ? exactMatches : jobs
}
function isCheckJobFailureState(state: string | null | undefined): boolean {
return (
state === 'failure' ||
state === 'failed' ||
state === 'action_required' ||
state === 'cancelled' ||
state === 'stale' ||
state === 'startup_failure' ||
state === 'timed_out'
)
}
function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null {
if (job.id === null) {
return null
}
return `${job.id}:${job.completedAt ?? ''}`
}
async function attachFailedJobLogTails(
jobs: PRCheckRunDetails['jobs'],
ownerRepo: OwnerRepo,
ghOptions: GhExecOptions
): Promise<void> {
const failedJobs = jobs
.filter((job) => {
const state = job.conclusion ?? job.status
return job.id !== null && isCheckJobFailureState(state)
})
.slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT)
// Why: failed workflows can have many jobs; cap log fetches so details remain
// a lazy, bounded follow-up request instead of a burst of hosted log downloads.
for (const job of failedJobs) {
const cacheKey = getCheckJobLogTailCacheKey(job)
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) {
console.warn('getPRCheckDetails workflow job log fetch failed:', err)
job.logTail = null
}
setPrCheckLogTailCache(cacheKey, job.logTail)
}
}
function getWorkflowRunIdFromCheckRun(
checkRun: Record<string, unknown> | null
): number | undefined {
const checkSuite = checkRun?.check_suite
if (!checkSuite || typeof checkSuite !== 'object') {
return undefined
}
const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run
if (!workflowRun || typeof workflowRun !== 'object') {
return undefined
}
const id = (workflowRun as { id?: unknown }).id
return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined
}
export async function getPRCheckDetails(
repoPath: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: OwnerRepo | null
},
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRCheckRunDetails | null> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
if (!ownerRepo) {
return null
}
await acquire()
try {
let checkRun: Record<string, unknown> | null = null
let annotations: PRCheckRunDetails['annotations'] = []
if (args.checkRunId) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`],
ghOptions
)
checkRun = JSON.parse(stdout) as Record<string, unknown>
try {
const annotationsResult = await ghExecFileAsync(
[
'api',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20`
],
ghOptions
)
annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout))
} catch (err) {
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) {
console.warn('getPRCheckDetails workflow jobs fetch failed:', err)
}
}
const output =
checkRun?.output && typeof checkRun.output === 'object'
? (checkRun.output as Record<string, unknown>)
: null
return {
name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check',
status: nullableString(checkRun?.status),
conclusion: nullableString(checkRun?.conclusion),
url: nullableString(checkRun?.html_url) ?? args.url ?? null,
detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null,
startedAt: nullableString(checkRun?.started_at),
completedAt: nullableString(checkRun?.completed_at),
title: nullableString(output?.title),
summary: nullableString(output?.summary),
text: nullableString(output?.text),
annotations,
jobs
}
} catch (err) {
console.warn('getPRCheckDetails failed:', err)
return null
} finally {
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 } = {},
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubRerunPRChecksResult> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getOwnerRepo(repoPath, 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 and thread IDs are only available via
// GraphQL. The REST pulls/{n}/comments endpoint does not expose them, so we
// use GraphQL for review threads and REST for issue-level comments.
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 {
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
path
reactionGroups {
content
reactors {
totalCount
}
}
}
}
}
}
comments(first: 100) {
nodes {
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
reactionGroups {
content
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?: OwnerRepo | null },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRComment[]> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = options?.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
if (ownerRepo) {
await assertRateLimitBudget('core')
}
await acquire()
try {
if (ownerRepo) {
// Why: --cache 60s saves rate-limit budget during normal loads, but when the
// user explicitly clicks refresh we must skip it so gh fetches fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
// Why: use allSettled so a single failing endpoint (e.g. GraphQL
// permissions, transient network error) doesn't blank out all comments.
// Each source is parsed independently; failed sources contribute zero
// comments instead of aborting the entire fetch.
const reviewThreadsGuard = rateLimitGuard('graphql')
let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null>
if (reviewThreadsGuard.blocked) {
reviewThreadsFetch = Promise.resolve(null)
} else {
noteRateLimitSpend('graphql')
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 comments) live
// under pulls/{n}/reviews, not under issue comments or review threads.
// Without this, a reviewer who submits "LGTM" without inline threads
// would have their comment silently dropped from the panel.
ghExecFileAsync(
['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`],
ghOptions
)
])
noteRateLimitSpend('core', 2)
// 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: {
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
path: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}[]
}
}
type GQLIssueComment = {
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}
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[] }
}
}
}
}
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',
reactions: mapGraphQLReactionGroups(c.reactionGroups)
})
)
if (graphQLIssueComments.length > 0) {
issueComments = graphQLIssueComments
}
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',
reactions: mapGraphQLReactionGroups(c.reactionGroups),
path: c.path,
threadId: thread.id,
isResolved: thread.isResolved,
isOutdated: thread.line == null,
// Why: GitHub nulls out line/startLine when the commented code is
// outdated (e.g. after a force-push). Fall back to originalLine which
// always preserves the line numbers from when the comment was created.
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)
}
}
// Parse review summaries (REST) — only include reviews with a body,
// since empty-body reviews (e.g. approvals with no comment) add 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 (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
)
noteRateLimitSpend('graphql')
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()
}
}
/**
* 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
pullRequestId: string
path: string
viewed: boolean
}): Promise<boolean> {
const ghOptions = ghRepoExecOptions(
githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)
)
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,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'
const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }`
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const guard = rateLimitGuard('graphql')
if (guard.blocked) {
console.warn(
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
)
return false
}
await acquire()
try {
noteRateLimitSpend('graphql')
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
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(),
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?: OwnerRepo | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubCommentResult> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/comments/${commentId}/replies`,
'--raw-field',
`body=${body}`
],
ghOptions
)
const data = JSON.parse(stdout) as Parameters<typeof mapReviewCommentResponse>[0]
if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) {
return { ok: false, error: 'Unexpected response from GitHub' }
}
return {
ok: true,
comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGhError(stderr).message }
} finally {
release()
}
}
export async function addPRReviewComment(
args: GitHubPRReviewCommentInput & {
connectionId?: string | null
localGitOptions?: LocalGitExecOptions
}
): Promise<GitHubCommentResult> {
const ghOptions = ghRepoExecOptions(
githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)
)
const ownerRepo = await getOwnerRepo(args.repoPath, 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?: OwnerRepo | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
await acquire()
try {
const mergeBlocker = await getPRMergeBlocker(
repoPath,
prNumber,
ownerRepo,
ghOptions,
connectionId,
localGitOptions
)
if (mergeBlocker) {
return { ok: false, error: mergeBlocker }
}
// Don't use --delete-branch: it tries to delete the local branch which
// fails when the user's worktree is checked out on it. Branch cleanup
// is handled by worktree deletion (local) and GitHub's auto-delete setting (remote).
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 {
release()
}
}
export async function setPRAutoMerge(
repoPath: string,
prNumber: number,
enabled: boolean,
method: GitHubPRMergeMethod = 'squash',
connectionId?: string | null,
prRepo?: OwnerRepo | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
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 enabling auto-merge on a PR that can already merge with
// "Pull request is in clean status". Surface the actionable next step (merge
// directly) instead of the raw GraphQL 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: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<PRAutoMergeIdentity | null> {
const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const { stdout } = await ghExecFileAsync(args, ghOptions)
const data = JSON.parse(stdout) as PRAutoMergeIdentity
return {
id: typeof data.id === 'string' ? data.id : undefined,
headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined,
baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined
}
}
async function runPRAutoMergeCommand(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<void> {
const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
}
async function shouldUseMergeQueueAutoMerge(
pr: PRAutoMergeIdentity,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<boolean> {
if (!ownerRepo || !pr.baseRefName) {
return false
}
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions)
return mergeMetadata.mergeQueueRequired === true
}
async function enablePRAutoMerge(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<{ ok: true } | { ok: false; error: string }> {
const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions)
if (!pr?.id) {
return { ok: false, error: 'Could not resolve GitHub pull request ID' }
}
if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) {
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 perform an immediate merge; this mutation
// only creates GitHub's auto-merge request and lets 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: OwnerRepo | null,
ghOptions: GhExecOptions,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string | null> {
if (!ownerRepo) {
return null
}
try {
const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions)
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; SSH repo paths are remote-only
// until that helper is routed through the SSH git 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 make
// merge impossible when the lookup endpoint has a transient 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,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(context)
const ownerRepo = await getOwnerRepo(repoPath, 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: GitHub can reject REST pull state PATCHes for reopen paths with a
// generic 422; gh's PR commands use GitHub's supported reopen flow.
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,
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 ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions)
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,
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 ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions)
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?: OwnerRepo | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
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?: OwnerRepo | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, 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()
}
}