diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 69ad8c72285..85ea75132d2 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -29,7 +29,6 @@ inline src/main/git/repo.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts -inline src/main/github/client.ts inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts inline src/main/github/project-view/mutations.ts diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 72ca0f77cff..43be8ba642e 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -568,6 +568,74 @@ describe('getPRChecks', () => { } ) }) + it('reports a resource-neutral not-found error when a workflow-run rerun 404s', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce( + graphQLChecksResponse({ + contexts: [ + graphQLCheckRun({ + name: 'lint', + conclusion: 'FAILURE', + detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88', + workflowRunId: 77 + }) + ] + }) + ) + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: gh api'), { + stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/actions/runs/77/rerun-failed-jobs)', + stdout: '' + }) + ) + + const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true }) + + expect(result).toEqual({ + ok: false, + error: 'GitHub resource to rerun was not found — it may have expired or been deleted.' + }) + }) + + it('reports a resource-neutral not-found error when a standalone check-run rerequest 404s', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce( + graphQLChecksResponse({ + contexts: [ + { + __typename: 'CheckRun', + databaseId: 88, + name: 'external-ci', + status: 'COMPLETED', + conclusion: 'FAILURE', + detailsUrl: 'https://ci.example.com/builds/88', + url: 'https://ci.example.com/builds/88', + checkSuite: { databaseId: 1000, workflowRun: null } + } + ] + }) + ) + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: gh api'), { + stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/check-runs/88/rerequest)', + stdout: '' + }) + ) + + const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', '-X', 'POST', 'repos/acme/widgets/check-runs/88/rerequest'], + expect.objectContaining({ cwd: '/repo-root' }) + ) + expect(result).toEqual({ + ok: false, + error: 'GitHub resource to rerun was not found — it may have expired or been deleted.' + }) + }) it('routes local WSL check retrieval and reruns through the selected distro', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } diff --git a/src/main/github/client.ts b/src/main/github/client.ts index b9e8d94226f..7bf17c42664 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1,5597 +1,44 @@ -/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps acquire/release and error handling consistent. */ -import type { ClassifiedError } from '../../shared/classified-error' -import type { - GitHubRerunPRChecksResult, - PRCheckDetail, - PRCheckRunDetails -} from '../../shared/github/check-types' -import type { - GitHubCommentResult, - GitHubPRReviewCommentInput, - GitHubReactionContent, - PRComment -} from '../../shared/github/comment-types' -import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' -import type { - GitHubPRMergeMethod, - GitHubPRMergeMethodSettings, - GitHubPRStack, - GitHubViewer, - PRConflictSummary, - PRInfo, - PRMergeableState, - PRReviewDecision -} from '../../shared/github/pull-request-types' -import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' -import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import type { GitPushTarget } from '../../shared/worktree/types' -import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' -import { - normalizeHostedReviewBaseRef, - normalizeHostedReviewHeadRef -} from '../../shared/hosted-review-refs' -import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github/pull-request-merge-methods' -import { summarizeProviderChecks } from '../../shared/provider-check-summary' -import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github/work-items-query-bounds' -import { classifyGitHubUnavailable } from '../../shared/github/api-availability' -import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' -import { - GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, - sortWorkItemsByNumber -} from '../../shared/work-items' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { sliceCheckLogTail } from '../../shared/check-job-log-tail-slice' -import { - classifyPRRefreshError, - safePRRefreshErrorMessage -} from './pr-refresh-error-classification' -import { getPRConflictSummary } from './conflict-summary' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' -import { splitRemoteBranchName } from '../../shared/git-effective-upstream' -import { - execFileAsync, - ghExecFileAsync, - gitExecFileAsync, - acquire, - release, - classifyGhError, - classifyListIssuesError, - classifyListPrsError, - ghRepoExecOptions, - githubRepoContext, - getRemoteUrlForRepo, - type LocalGitExecOptions, - type OwnerRepo -} from './gh-utils' -// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions. -import { extractExecError, parseRetryAfterMs } from '../git/exec-error' -import { - isCommitPartOfMergedPR, - type MergedPRCommitMembership -} from './merged-pr-commit-membership' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { - hasHostedReviewLocalGitOptions, - getHostedReviewLocalGitOptions, - type HostedReviewExecutionOptions -} from '../source-control/hosted-review-git-options' -import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' -import { readLocalGitConfigSignature } from './local-git-config-signature' -import { - getGitHubApiRepositoryForRemote, - getOriginGitHubApiRepository, - githubHostExecOptions, - githubRepositorySlugArg, - githubRepositoryWebHost, - resolveGitHubApiRepository, - resolveGitHubApiRepositoryCandidates, - resolveGitHubRepoExecution, - resolveIssueGitHubApiRepositorySource, - type GitHubRepoExecOptions, - type GitHubApiRepository -} from './github-api-repository' -import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' +export { + _getMergeQueueCacheSizeForTests, + _resetMergeQueueCacheForTests +} from './client/detect/repository-merge-metadata-cache' +export { _resetPRStackSummaryCacheForTests } from './client/lookup/pr-stack-summary-cache' +export { + _getTrackedUpstreamBranchCacheSizesForTests, + __resetTrackedUpstreamBranchCacheForTests +} from './client/lookup/tracked-upstream-cache' +export { addPRReviewComment, addPRReviewCommentReply } from './client/create/add-pr-review-comment' +export { checkOrcaStarred, starOrca } from './client/fetch/orca-star' +export { countWorkItems } from './client/list/count-work-items' +export { createGitHubPullRequest } from './client/create/create-github-pull-request' +export { getAuthenticatedViewer } from './client/fetch/authenticated-viewer' +export { getGitHubPRLookupRateLimitBlock } from './client/lookup/pr-lookup-rate-limit' +export { getPRCheckDetails } from './client/check/get-pr-check-details' +export { getPRChecks } from './client/check/get-pr-checks' +export { getPRComments } from './client/fetch/get-pr-comments' +export { getPRForBranch } from './client/lookup/get-pr-for-branch' +export { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +export { + getPullRequestPushTarget, + type PullRequestPushTarget +} from './client/lookup/pull-request-push-target' +export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream' +export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item' +export { listWorkItems } from './client/list/list-work-items' +export { mergePR } from './client/merge/merge-pr' +export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers' +export { rerunPRChecks } from './client/check/rerun-pr-checks' +export { resolveReviewThread } from './client/update/resolve-review-thread' +export { setPRAutoMerge } from './client/merge/pr-auto-merge' +export { setPRCommentReaction } from './client/update/pr-comment-reaction' +export { setPRFileViewed } from './client/update/pr-file-viewed' +export { updatePRDetails, updatePRTitle } from './client/update/pr-details' +export { updatePRState } from './client/update/pr-state' +export type { GitHubPRBranchLookupOptions } from './client/lookup/pull-request-lookup-data' +export type { MainWorkItem } from './client/map/work-item-field-coercion' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, listIssues } from './issues' export { createIssue } from './issue-create' export { updateIssue } from './issue-update' export { addIssueComment } from './issue-comment' export { listLabels, listAssignableUsers } from './issue-field-options' -import { - mapCheckRunRESTStatus, - mapCheckRunRESTConclusion, - mapCommitStatusRESTStatus, - mapCommitStatusRESTConclusion, - mapCheckStatus, - mapCheckConclusion, - mapPRState, - deriveCheckStatus -} from './mappers' -import { - mapGraphQLReactionGroups, - toGraphQLReactionContent, - type GitHubGraphQLReactionGroup -} from './comment-reactions' -import { - getRateLimit, - noteRepositoryRateLimitSpend, - repositoryRateLimitGuard, - spendsSharedGitHubComQuota, - type RateLimitBucketKind -} from './rate-limit' -import { - GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, - GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE -} from '../../shared/github/check-details-deadline' -import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack' - -type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal } -type HostedReviewLocalGitOptions = ReturnType - -const ORCA_REPO = 'stablyai/orca' -const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5 -// Why: each entry holds up to 16KB of log text; bound the cache so a long session can't grow it unbounded. -const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128 -const prCheckLogTailCache = new Map() - -function hostedReviewLocalGitOptionArgs( - options: HostedReviewExecutionOptions = {} -): [] | [HostedReviewLocalGitOptions] { - return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] -} - -function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void { - prCheckLogTailCache.set(cacheKey, logTail) - while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) { - const oldestKey = prCheckLogTailCache.keys().next().value - if (oldestKey === undefined) { - break - } - prCheckLogTailCache.delete(oldestKey) - } -} - -function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void { - if (signal?.aborted) { - throw error - } -} - -function waitForCheckDetailsResolution(operation: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(signal.reason) - } - return new Promise((resolve, reject) => { - const finish = (settle: () => void): void => { - signal.removeEventListener('abort', onAbort) - settle() - } - const onAbort = (): void => finish(() => reject(signal.reason)) - signal.addEventListener('abort', onAbort, { once: true }) - void operation.then( - (value) => finish(() => resolve(value)), - (error) => finish(() => reject(error)) - ) - }) -} -const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 -const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 -const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256 -type GitHubRepositoryMergeMetadata = { - mergeQueueRequired: boolean | null - autoMergeAllowed: boolean | null - mergeMethodSettings?: GitHubPRMergeMethodSettings -} -const repositoryMergeMetadataCache = new Map< - string, - { value: GitHubRepositoryMergeMetadata; expiresAt: number } ->() -const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 -const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 -const STACK_METADATA_UNAVAILABLE_ERROR = - 'Could not verify GitHub pull request stack metadata. Refresh and try again.' -const prStackSummaryCache = new Map< - string, - { value: GitHubPRStack | undefined; expiresAt: number } ->() -const prStackSummaryInFlight = new Map>() - -function githubPRStackExecutionScope( - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): string { - return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` -} - -export function _resetMergeQueueCacheForTests(): void { - repositoryMergeMetadataCache.clear() -} - -export function _resetPRStackSummaryCacheForTests(): void { - prStackSummaryCache.clear() - prStackSummaryInFlight.clear() -} - -export function _getMergeQueueCacheSizeForTests(): number { - return repositoryMergeMetadataCache.size -} - -function pruneRepositoryMergeMetadataCache(now = Date.now()): void { - for (const [cacheKey, cached] of repositoryMergeMetadataCache) { - if (cached.expiresAt <= now) { - repositoryMergeMetadataCache.delete(cacheKey) - } - } - while (repositoryMergeMetadataCache.size > MERGE_QUEUE_CACHE_MAX_ENTRIES) { - const oldestKey = repositoryMergeMetadataCache.keys().next().value - if (oldestKey === undefined) { - break - } - repositoryMergeMetadataCache.delete(oldestKey) - } -} - -async function assertRateLimitBudget( - bucket: RateLimitBucketKind, - repository?: GitHubApiRepository | null, - executionOptions?: Pick -): Promise { - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - await getRateLimit() - } - const guard = repositoryRateLimitGuard(repository, bucket, executionOptions) - if (guard.blocked) { - throw new Error( - `GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}` - ) - } -} - -// Why: a branch lookup prefers REST but can fall back to `gh pr list` and -// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh -// coordinator's own estimate. -const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const - -/** - * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh - * coordinator's queue (#11532). - * - * The coordinator guards and paces its own background refreshes, but - * `hostedReview:forBranch` polls the same lookup straight from the renderer. - * Ungated, the two paths together could spend the user's entire hourly quota — - * which is per user and shared with their own `gh` and CLI agents. - * Returns the reset time when the caller must not spend, else `null`. - */ -export async function getGitHubPRLookupRateLimitBlock( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ resetAt: number } | null> { - const executionOptions = ghRepoExecOptions( - githubRepoContext(repoPath, connectionId, localGitOptions) - ) - // Why: identity resolution runs local git, which can fail for reasons that - // have nothing to do with the budget; let the lookup itself classify those. - const repository = await getOriginGitHubApiRepository( - repoPath, - connectionId, - executionOptions - ).catch(() => null) - if (repository === null) { - return null - } - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the snapshot and is exempt from limits, so a - // failure must fail open rather than block the lookup (#7553). - await getRateLimit().catch(() => undefined) - } - // Why: retrying at the earlier reset would fail again on the bucket that has - // not reset yet, so the latest blocked reset is the only honest retry time. - const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) => - repositoryRateLimitGuard(repository, bucket, executionOptions) - ).flatMap((guard) => (guard.blocked ? [guard.resetAt] : [])) - return resets.length > 0 ? { resetAt: Math.max(...resets) } : null -} - -function prRefreshUpstreamError( - err: unknown -): Extract { - const errorType = classifyPRRefreshError(err) - const outcome: Extract = { - kind: 'upstream-error', - errorType, - message: safePRRefreshErrorMessage(errorType), - fetchedAt: Date.now() - } - // Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429. - if (errorType === 'rate_limited') { - const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr) - if (retryAfterMs !== null && retryAfterMs > 0) { - const retryAt = Date.now() + retryAfterMs - outcome.nextAutoRetryAt = retryAt - outcome.retryDisabledUntil = retryAt - } - } - return outcome -} - -function isNoPullRequestError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return /no pull requests? found|could not find.*pull request/i.test(message) -} - -/** - * Check if the authenticated user has starred the Orca repo. - * Returns true if starred, false if not, null if unable to determine (gh unavailable). - */ -export async function checkOrcaStarred(): Promise { - await acquire() - try { - const { stdout, stderr } = await execFileAsync( - 'gh', - ['api', '--include', `user/starred/${ORCA_REPO}`], - { encoding: 'utf-8' } - ) - const response = `${stdout ?? ''}\n${stderr ?? ''}` - if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) { - return true - } - return null - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - // 404 means the user hasn't starred — the only expected "no" answer - if (message.includes('HTTP 404')) { - return false - } - // Anything else (gh not installed, not authenticated, network issue) - return null - } finally { - release() - } -} - -function pickPushRemoteUrl(args: { - originUrl: string | null - cloneUrl: string - sshUrl: string -}): string { - const { originUrl, cloneUrl, sshUrl } = args - if (originUrl && (/^(git@|ssh:)/.test(originUrl) || originUrl.includes('ssh.github.com'))) { - return sshUrl - } - return cloneUrl -} - -function sanitizeRemoteName(owner: string, repo: string): string { - const slug = `${owner}-${repo}` - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^[.-]+|[.-]+$/g, '') - return slug ? `pr-${slug}` : 'pr-head' -} - -/** - * A fork push target plus the PR's `maintainer_can_modify` flag, kept outside - * {@link GitPushTarget} so it never leaks into the persisted push-target shape. - */ -export type PullRequestPushTarget = { - pushTarget: GitPushTarget - /** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */ - maintainerCanModify?: boolean -} - -// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/ -// undefined keep the multi-candidate probe ordered upstream-first, matching -// resolvePrWorkItemSource list semantics. -async function resolvePullRequestLookupCandidates( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (preference === 'origin') { - const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions) - return origin ? [origin] : [] - } - return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions)) - .candidates -} - -export async function getPullRequestPushTarget( - repoPath: string, - prNumber: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {}, - preference?: IssueSourcePreference -): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const candidates = await resolvePullRequestLookupCandidates( - repoPath, - preference, - connectionId, - localGitOptions - ) - if (candidates.length === 0) { - return null - } - - await acquire() - try { - let prStdout = '' - let matchedRepository: GitHubApiRepository | null = null - for (const candidate of candidates) { - try { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`], - { ...ghOptions, ...githubHostExecOptions(candidate) } - ) - prStdout = stdout - matchedRepository = candidate - break - } catch (error) { - // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up. - if (isNotFoundGhError(error)) { - continue - } - throw error - } - } - if (!prStdout || !matchedRepository) { - return null - } - const origin = await getGitHubApiRepositoryForRemote( - repoPath, - 'origin', - connectionId, - localGitOptions - ) - const pr = JSON.parse(prStdout) as { - maintainer_can_modify?: boolean - head?: { - ref?: string - repo?: { - full_name?: string - clone_url?: string - ssh_url?: string - owner?: { login?: string } - name?: string - } | null - } - } - const headRepo = pr.head?.repo - const branchName = pr.head?.ref?.trim() - const owner = headRepo?.owner?.login?.trim() - const repo = headRepo?.name?.trim() ?? headRepo?.full_name?.split('/')[1]?.trim() - const cloneUrl = headRepo?.clone_url?.trim() - const sshUrl = headRepo?.ssh_url?.trim() - const maintainerCanModify = - typeof pr.maintainer_can_modify === 'boolean' ? pr.maintainer_can_modify : undefined - if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) { - return null - } - if ( - origin && - githubRepoIdentityKey(origin) === - githubRepoIdentityKey({ owner, repo, host: matchedRepository.host }) - ) { - return { - pushTarget: { remoteName: 'origin', branchName }, - ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) - } - } - - let originUrl: string | null = null - try { - const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin') - originUrl = rawOriginUrl?.trim() || null - } catch { - originUrl = null - } - return { - pushTarget: { - remoteName: sanitizeRemoteName(owner, repo), - branchName, - remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl }) - }, - ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) - } - } finally { - release() - } -} - -/** - * Star the Orca repo for the authenticated user. - */ -export async function starOrca(): Promise { - await acquire() - try { - await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], { - encoding: 'utf-8' - }) - return true - } catch { - return false - } finally { - release() - } -} - -/** - * Get the authenticated GitHub viewer when gh is available and logged in. - * Returns null when gh is unavailable, unauthenticated, or the lookup fails. - */ -export async function getAuthenticatedViewer(): Promise { - await acquire() - try { - const { stdout } = await execFileAsync( - 'gh', - ['api', 'user', '--jq', '{login: .login, email: .email}'], - { encoding: 'utf-8' } - ) - const viewer = JSON.parse(stdout) as { login?: string; email?: string | null } - if (!viewer.login?.trim()) { - return null - } - return { - login: viewer.login.trim(), - email: viewer.email?.trim() || null - } - } catch { - return null - } finally { - release() - } -} - -// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC. -export type MainWorkItem = Omit - -// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages. -const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' - -const WORK_ITEM_PR_LIST_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' - -// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL. -// Requested reviewers stay in the list payload because Tasks renders that column on first paint. -const WORK_ITEM_PR_DETAIL_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' - -function mapIssueWorkItem(item: Record): MainWorkItem { - return { - id: `issue:${String(item.number)}`, - type: 'issue', - number: Number(item.number), - title: String(item.title ?? ''), - state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open', - url: String(item.html_url ?? item.url ?? ''), - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => - typeof label === 'object' && label !== null && 'name' in label - ? String((label as { name?: unknown }).name ?? '') - : '' - ) - .filter(Boolean) - : [], - updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - ...authorFieldsFromUnknown(item), - ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}) - } -} - -/** - * Derive author login + avatar_url together so GHE avatars render — the login-only - * `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784). - */ -function authorFieldsFromUnknown( - item: Record -): Pick { - const user = userFromUnknown(item.user ?? item.author) - if (!user) { - return { author: null } - } - return { - author: user.login, - ...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {}) - } -} - -function extractHeadOwnerLogin(item: Record): string | null { - // gh CLI `pr list --json headRepositoryOwner` shape: { login } - if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) { - const login = (item.headRepositoryOwner as { login?: unknown }).login - if (typeof login === 'string' && login.trim()) { - return login - } - } - // REST API `pull_request` shape: head.repo.owner.login - if (typeof item.head === 'object' && item.head !== null) { - const head = item.head as { repo?: unknown; user?: unknown; label?: unknown } - const repo = head.repo - if (typeof repo === 'object' && repo !== null) { - const owner = (repo as { owner?: unknown }).owner - if (typeof owner === 'object' && owner !== null) { - const login = (owner as { login?: unknown }).login - if (typeof login === 'string' && login.trim()) { - return login - } - } - } - // Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label. - const user = head.user - if (typeof user === 'object' && user !== null) { - const login = (user as { login?: unknown }).login - if (typeof login === 'string' && login.trim()) { - return login - } - } - if (typeof head.label === 'string') { - const owner = head.label.split(':', 1)[0]?.trim() - if (owner) { - return owner - } - } - } - return null -} - -function userFromUnknown( - value: unknown -): { login: string; name: string | null; avatarUrl: string } | null { - if (typeof value === 'string') { - const login = value.trim() - return login ? { login, name: null, avatarUrl: '' } : null - } - if (typeof value !== 'object' || value === null) { - return null - } - const raw = value as Record - const login = typeof raw.login === 'string' ? raw.login.trim() : '' - if (!login) { - return null - } - const databaseId = numberFromUnknown(raw.databaseId) - return { - login, - name: typeof raw.name === 'string' ? raw.name : null, - avatarUrl: - typeof raw.avatarUrl === 'string' - ? raw.avatarUrl - : typeof raw.avatar_url === 'string' - ? raw.avatar_url - : databaseId !== undefined - ? `https://avatars.githubusercontent.com/u/${databaseId}?v=4` - : '' - } -} - -function usersFromUnknown( - value: unknown -): { login: string; name: string | null; avatarUrl: string }[] { - if (!Array.isArray(value)) { - return [] - } - const users: { login: string; name: string | null; avatarUrl: string }[] = [] - for (const entry of value) { - const direct = userFromUnknown(entry) - if (direct) { - users.push(direct) - continue - } - if (typeof entry === 'object' && entry !== null) { - const raw = entry as Record - const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author) - if (nested) { - users.push(nested) - } - } - } - return users -} - -function latestReviewsFromUnknown(value: unknown): NonNullable { - if (!Array.isArray(value)) { - return [] - } - const reviews: NonNullable = [] - for (const entry of value) { - if (typeof entry !== 'object' || entry === null) { - continue - } - const raw = entry as Record - const author = userFromUnknown(raw.author) - if (!author) { - continue - } - reviews.push({ - login: author.login, - state: typeof raw.state === 'string' ? raw.state : null, - avatarUrl: author.avatarUrl - }) - } - return reviews -} - -function numberFromUnknown(value: unknown): number | undefined { - const number = typeof value === 'number' ? value : Number(value) - return Number.isFinite(number) ? number : undefined -} - -function normalizePRMergeable(value: unknown): PRMergeableState | undefined { - const raw = typeof value === 'string' ? value.toUpperCase() : '' - if (raw === 'MERGEABLE' || raw === 'CONFLICTING' || raw === 'UNKNOWN') { - return raw - } - if (typeof value === 'boolean') { - return value ? 'MERGEABLE' : 'CONFLICTING' - } - return undefined -} - -function normalizeReviewDecision(value: unknown): PRReviewDecision | null { - return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED' - ? value - : null -} - -function isAutoMergeEnabled(value: unknown): boolean { - return typeof value === 'object' && value !== null -} - -function checkRollupEntries(value: unknown): unknown[] { - if (Array.isArray(value)) { - return value - } - if (typeof value !== 'object' || value === null) { - return [] - } - const raw = value as Record - const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes - return Array.isArray(nodes) ? nodes : [] -} - -function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] { - return summarizeProviderChecks( - checkRollupEntries(value).map((entry) => { - if (typeof entry !== 'object' || entry === null) { - return { status: '', conclusion: '' } - } - const raw = entry as Record - // Why: StatusContext reports `state` and carries no `status`; CheckRun reports both. - return { - status: String(raw.status ?? ''), - conclusion: String(raw.conclusion ?? raw.state ?? '') - } - }) - ) -} - -function mapPullRequestWorkItem( - item: Record, - baseOwnerRepo: OwnerRepo | null = null -): MainWorkItem { - // Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner. - const headOwnerLogin = extractHeadOwnerLogin(item) - // Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork". - const isCrossRepository = - headOwnerLogin !== null && baseOwnerRepo !== null - ? headOwnerLogin !== baseOwnerRepo.owner - : null - const state = String(item.state ?? '').toLowerCase() - const additions = numberFromUnknown(item.additions) - const deletions = numberFromUnknown(item.deletions) - const changedFiles = numberFromUnknown( - item.changedFiles ?? - item.changed_files ?? - (item.files as { totalCount?: unknown } | undefined)?.totalCount - ) - const mergeable = normalizePRMergeable(item.mergeable) - const headSha = - typeof item.headRefOid === 'string' - ? item.headRefOid - : typeof item.head === 'object' && item.head !== null - ? typeof (item.head as { sha?: unknown }).sha === 'string' - ? (item.head as { sha: string }).sha - : undefined - : undefined - return { - id: `pr:${String(item.number)}`, - type: 'pr', - number: Number(item.number), - title: String(item.title ?? ''), - state: - state === 'merged' || item.merged_at || item.mergedAt - ? 'merged' - : state === 'closed' - ? 'closed' - : item.isDraft || item.draft - ? 'draft' - : 'open', - url: String(item.html_url ?? item.url ?? ''), - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => - typeof label === 'object' && label !== null && 'name' in label - ? String((label as { name?: unknown }).name ?? '') - : '' - ) - .filter(Boolean) - : [], - updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - ...authorFieldsFromUnknown(item), - branchName: - typeof item.head === 'object' && item.head !== null && 'ref' in item.head - ? String((item.head as { ref?: unknown }).ref ?? '') - : String(item.headRefName ?? ''), - baseRefName: - typeof item.base === 'object' && item.base !== null && 'ref' in item.base - ? String((item.base as { ref?: unknown }).ref ?? '') - : String(item.baseRefName ?? ''), - ...(headSha ? { headSha } : {}), - ...(baseOwnerRepo - ? { - prRepo: { - owner: baseOwnerRepo.owner, - repo: baseOwnerRepo.repo, - ...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {}) - } - } - : {}), - ...(additions !== undefined ? { additions } : {}), - ...(deletions !== undefined ? { deletions } : {}), - ...(changedFiles !== undefined ? { changedFiles } : {}), - ...('reviewDecision' in item - ? { reviewDecision: normalizeReviewDecision(item.reviewDecision) } - : {}), - ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined - ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) } - : {}), - ...(item.latestReviews !== undefined - ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } - : {}), - ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}), - ...(item.statusCheckRollup !== undefined - ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) } - : {}), - ...(mergeable ? { mergeable } : {}), - ...('autoMergeRequest' in item - ? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) } - : {}), - ...('mergeStateStatus' in item - ? { - mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null - } - : {}), - ...(typeof item.maintainerCanModify === 'boolean' - ? { maintainerCanModify: item.maintainerCanModify } - : {}), - ...(isCrossRepository !== null ? { isCrossRepository } : {}) - } -} - -async function hydrateWorkItemRepositoryMergeMetadata( - items: MainWorkItem[], - ownerRepo: OwnerRepo | null, - ghOptions: GhExecOptions -): Promise { - const hasPullRequest = items.some((item) => item.type === 'pr') - if (!ownerRepo || !hasPullRequest) { - return items - } - // Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out. - const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, undefined, ghOptions) - if (!mergeMetadata.mergeMethodSettings && mergeMetadata.autoMergeAllowed === null) { - return items - } - return items.map((item) => - item.type === 'pr' - ? { - ...item, - ...(mergeMetadata.autoMergeAllowed !== null - ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } - : {}), - ...(mergeMetadata.mergeMethodSettings - ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) - } - : item - ) -} - -async function fetchIssueWorkItem( - repoPath: string, - ownerRepo: GitHubApiRepository | null, - number: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], - ghOptions - ) - const item = JSON.parse(stdout) as Record - if ('pull_request' in item) { - return null - } - return mapIssueWorkItem(item) - } - - if (connectionId) { - // Why: SSH-backed gh has no repository cwd. A bare lookup could honor the - // local process GH_REPO/GH_HOST and return an unrelated repository item. - return null - } - - const { stdout } = await ghExecFileAsync( - ['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'], - ghOptions - ) - return mapIssueWorkItem(JSON.parse(stdout) as Record) -} - -// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty. -const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' - -async function fetchPullRequestReviewFields( - number: number, - ownerRepo: GitHubApiRepository | null, - ghOptions: GhExecOptions -): Promise> { - try { - const args = ownerRepo - ? [ - 'pr', - 'view', - String(number), - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--json', - WORK_ITEM_PR_REVIEW_JSON_FIELDS - ] - : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS] - const { stdout } = await ghExecFileAsync(args, ghOptions) - const item = JSON.parse(stdout) as Record - return { - ...(item.reviewRequests !== undefined - ? { reviewRequests: usersFromUnknown(item.reviewRequests) } - : {}), - ...(item.latestReviews !== undefined - ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } - : {}) - } - } catch { - return {} - } -} - -async function fetchPullRequestWorkItem( - repoPath: string, - ownerRepo: GitHubApiRepository | null, - number: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (ownerRepo) { - try { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - String(number), - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--json', - WORK_ITEM_PR_DETAIL_JSON_FIELDS - ], - ghOptions - ) - const item = JSON.parse(stdout) as Record - const mapped = mapPullRequestWorkItem(item, ownerRepo) - // Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists. - const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined - try { - const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) - return { - ...mapped, - mergeQueueRequired: mergeMetadata.mergeQueueRequired, - ...(mergeMetadata.autoMergeAllowed !== null - ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } - : {}), - ...(mergeMetadata.mergeMethodSettings - ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) - } - } catch { - return mapped - } - } catch { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions - ) - const mapped = mapPullRequestWorkItem( - JSON.parse(stdout) as Record, - ownerRepo - ) - const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions) - return { ...mapped, ...reviewFields } - } - } - - if (connectionId) { - // Why: connection-backed gh cannot infer a repository from cwd. Refuse a - // bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup. - return null - } - - const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS], - ghOptions - ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record) -} - -async function fetchPullRequestWorkItemFromCandidates( - repoPath: string, - number: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {}, - preference?: IssueSourcePreference -): Promise { - const candidates = await resolvePullRequestLookupCandidates( - repoPath, - preference, - connectionId, - localGitOptions - ) - if (candidates.length === 0) { - if (preference === 'origin') { - return null - } - return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions) - } - for (const candidate of candidates) { - try { - return await fetchPullRequestWorkItem( - repoPath, - candidate, - number, - connectionId, - localGitOptions - ) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - const classification = classifyGhError(message).type - if (classification !== 'not_found' && classification !== 'permission_denied') { - throw err - } - } - } - return null -} - -type WorkItemListRequest = { - args: string[] - offset: number -} - -function normalizeWorkItemPage(page: number | undefined): number { - return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1 -} - -function buildWorkItemListRequest(args: { - kind: 'issue' | 'pr' - ownerRepo: OwnerRepo - limit: number - query: ParsedTaskQuery - page: number -}): WorkItemListRequest { - const { kind, ownerRepo, limit, query, page } = args - const searchParts: string[] = [] - - if (kind === 'issue') { - searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) - } - searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') - - if (query.state === 'open') { - searchParts.push('is:open') - } else if (query.state === 'closed') { - searchParts.push('is:closed') - if (kind === 'pr') { - searchParts.push('-is:merged') - } - } else if (query.state === 'merged') { - searchParts.push('is:merged') - } - - if (kind === 'pr' && query.draft) { - searchParts.push('draft:true') - } - - if (query.assignee) { - searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) - } - if (query.author) { - searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`) - } - if (query.labels.length > 0) { - for (const label of query.labels) { - searchParts.push(`label:${quoteGitHubSearchValue(label)}`) - } - } - if (kind === 'pr' && query.reviewRequested) { - searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) - } - if (kind === 'pr' && query.reviewedBy) { - searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) - } - if (query.freeText) { - searchParts.push(query.freeText) - } - - if (kind === 'issue') { - return { - args: [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`, - '--jq', - '.items' - ], - offset: 0 - } - } - - // Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort. - searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) - const out = [ - 'pr', - 'list', - '--limit', - String(Math.min(page * limit, 1000)), - '--state', - 'all', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ] - out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - out.push('--search', searchParts.join(' ')) - return { args: out, offset: (page - 1) * limit } -} - -// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485). -type PartialWorkItemsResult = { - items: MainWorkItem[] - issuesError?: ClassifiedError - prsError?: ClassifiedError -} - -function assertSshRepoHasResolvedGitHubSource(args: { - connectionId?: string | null - issueOwnerRepo: OwnerRepo | null - prOwnerRepo: OwnerRepo | null -}): void { - if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) { - return - } - // Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state. - throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) -} - -type ResolvedPrWorkItemSource = { - source: OwnerRepo | null - originCandidate: OwnerRepo | null - upstreamCandidate: OwnerRepo | null -} - -async function resolvePrWorkItemSource( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const [originCandidate, upstreamCandidate] = await Promise.all([ - getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions), - getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions) - ]) - // Why: fork-contribution PRs live on the upstream repo (the fork's own PR - // list is almost always empty), so 'auto' resolves upstream-first exactly - // like the issue side. Only an explicit 'origin' pick pins PRs to the fork. - const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate) - return { source, originCandidate, upstreamCandidate } -} - -async function listRecentWorkItems( - repoPath: string, - issueOwnerRepo: OwnerRepo | null, - prOwnerRepo: OwnerRepo | null, - limit: number, - page: number, - connectionId?: string | null, - noCache?: boolean, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) - const recentQuery = parseTaskQuery('is:open') - const issueRequest = issueOwnerRepo - ? buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query: recentQuery, - page - }) - : null - const prRequest = prOwnerRepo - ? buildWorkItemListRequest({ - kind: 'pr', - ownerRepo: prOwnerRepo, - limit, - query: recentQuery, - page - }) - : null - if (noCache && issueRequest) { - issueRequest.args.splice(1, 2) - } - // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). - // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). - const [issuesSettled, prsSettled] = await Promise.allSettled([ - issueRequest && issueOwnerRepo - ? ghExecFileAsync(issueRequest.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) - }) - : Promise.resolve({ stdout: '[]' }), - prRequest && prOwnerRepo - ? ghExecFileAsync(prRequest.args, { - ...ghOptions, - ...githubHostExecOptions(prOwnerRepo) - }) - : Promise.resolve({ stdout: '[]' }) - ]) - - let issues: MainWorkItem[] = [] - let issuesError: ClassifiedError | undefined - if (issuesSettled.status === 'fulfilled') { - issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - } else { - const stderr = - issuesSettled.reason instanceof Error - ? issuesSettled.reason.message - : String(issuesSettled.reason) - issuesError = classifyListIssuesError(stderr) - } - - let prs: MainWorkItem[] = [] - if (prsSettled.status === 'fulfilled') { - prs = (JSON.parse(prsSettled.value.stdout) as Record[]) - .slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit) - .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, { - ...ghOptions, - ...githubHostExecOptions(prOwnerRepo) - }) - } else { - // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076). - // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop. - if (issuesError) { - console.warn( - 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', - issuesError.type, - issuesError.message - ) - } - throw prsSettled.reason - } - - return { - items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), - issuesError - } -} - -async function listQueriedWorkItems( - repoPath: string, - issueOwnerRepo: OwnerRepo | null, - prOwnerRepo: OwnerRepo | null, - query: ParsedTaskQuery, - limit: number, - page?: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) - const hasPrOnlyFilter = - query.state === 'merged' || - query.draft || - query.reviewRequested !== null || - query.reviewedBy !== null - const issueScope = query.scope !== 'pr' && !hasPrOnlyFilter - const prScope = query.scope !== 'issue' - let successfulRequestCount = 0 - let nonAvailabilityFailureCount = 0 - let availabilityError: unknown - let prsError: ClassifiedError | undefined - - // Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6). - const issueFetch = (async (): Promise => { - if (!issueScope) { - return { items: [] } - } - if (!issueOwnerRepo) { - return { items: [] } - } - const request = buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query, - page: page ?? 1 - }) - try { - const { stdout } = await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) - }) - const items = (JSON.parse(stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - successfulRequestCount += 1 - return { items } - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err) - if (classifyGitHubUnavailable(stderr)) { - availabilityError ??= err - } else { - nonAvailabilityFailureCount += 1 - } - return { items: [], issuesError: classifyListIssuesError(stderr) } - } - })() - - const prFetch = (async (): Promise => { - if (!prScope) { - return [] - } - if (!prOwnerRepo) { - return [] - } - const request = buildWorkItemListRequest({ - kind: 'pr', - ownerRepo: prOwnerRepo, - limit, - query, - page: page ?? 1 - }) - try { - const { stdout } = await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(prOwnerRepo) - }) - const mapped = (JSON.parse(stdout) as Record[]) - .slice(request.offset, request.offset + limit) - .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, { - ...ghOptions, - ...githubHostExecOptions(prOwnerRepo) - }) - successfulRequestCount += 1 - if (query.state === 'closed') { - return hydrated.filter((item) => item.state !== 'merged') - } - return hydrated - } catch (err) { - console.warn('listQueriedWorkItems PRs partial failure:', err) - const stderr = err instanceof Error ? err.message : String(err) - prsError = classifyListPrsError(stderr) - if (classifyGitHubUnavailable(stderr)) { - availabilityError ??= err - } else { - nonAvailabilityFailureCount += 1 - } - return [] - } - })() - - const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) - if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) { - // Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data. - throw availabilityError - } - return { - items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), - issuesError: issueResult.issuesError, - prsError - } -} - -export async function listWorkItems( - repoPath: string, - limit = 24, - query?: string, - page?: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - noCache?: boolean, - localGitOptions: LocalGitExecOptions = {} -): Promise> { - const trimmedQuery = query?.trim() ?? '' - const requestedPage = normalizeWorkItemPage(page) - if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { - return { - items: [], - sources: { - issues: null, - prs: null, - originCandidate: null, - upstreamCandidate: null - } - } - } - const [issueResolved, prResolved] = await Promise.all([ - resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), - resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) - ]) - const issueOwnerRepo = issueResolved.source - const prOwnerRepo = prResolved.source - await acquire() - try { - // Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures. - const partial = !trimmedQuery - ? await listRecentWorkItems( - repoPath, - issueOwnerRepo, - prOwnerRepo, - limit, - requestedPage, - connectionId, - noCache, - localGitOptions - ) - : await listQueriedWorkItems( - repoPath, - issueOwnerRepo, - prOwnerRepo, - parseTaskQuery(trimmedQuery), - limit, - requestedPage, - connectionId, - localGitOptions - ) - - const errors = - partial.issuesError || partial.prsError - ? { - ...(partial.issuesError ? { issues: partial.issuesError } : {}), - ...(partial.prsError ? { prs: partial.prsError } : {}) - } - : undefined - return { - items: partial.items, - sources: { - issues: issueOwnerRepo, - prs: prOwnerRepo, - originCandidate: prResolved.originCandidate, - upstreamCandidate: prResolved.upstreamCandidate - }, - ...(errors ? { errors } : {}), - ...(issueResolved.fellBack ? { issueSourceFellBack: true } : {}) - } - } finally { - release() - } -} - -function buildSearchQueryString( - ownerRepo: { owner: string; repo: string }, - query: ParsedTaskQuery -): string { - const parts: string[] = [`repo:${ownerRepo.owner}/${ownerRepo.repo}`] - if (query.scope === 'pr') { - parts.push('is:pull-request') - } else if (query.scope === 'issue') { - parts.push('is:issue') - } - if (query.state === 'open') { - parts.push('is:open') - } else if (query.state === 'closed') { - // Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge. - parts.push('is:closed') - if (query.scope !== 'issue') { - parts.push('-is:merged') - } - } else if (query.state === 'merged') { - parts.push('is:merged') - } - if (query.draft) { - parts.push('draft:true') - } - if (query.assignee) { - parts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) - } - if (query.author) { - parts.push(`author:${quoteGitHubSearchValue(query.author)}`) - } - if (query.reviewRequested) { - parts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) - } - if (query.reviewedBy) { - parts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) - } - for (const label of query.labels) { - parts.push(`label:${quoteGitHubSearchValue(label)}`) - } - if (query.freeText) { - parts.push(query.freeText) - } - return parts.join(' ') -} - -function quoteGitHubSearchValue(value: string): string { - return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value -} - -async function countWorkItemsForQuery( - repoPath: string, - ownerRepo: OwnerRepo, - query: ParsedTaskQuery, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`, - '--jq', - '.total_count' - ], - ghOptions - ) - // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. - noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions) - return Number.parseInt(stdout.trim(), 10) || 0 -} - -function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { - // Why: casing does not distinguish GitHub repos, but the same slug on different hosts does. - return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right)) -} - -function defaultOpenWorkItemQuery(): ParsedTaskQuery { - return { - scope: 'all', - state: 'open', - draft: false, - assignee: null, - author: null, - reviewRequested: null, - reviewedBy: null, - labels: [], - freeText: '' - } -} - -// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total. -export async function countWorkItems( - repoPath: string, - query?: string, - preference?: IssueSourcePreference, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const trimmedQuery = query?.trim() ?? '' - if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { - return 0 - } - const [issueResolved, prResolved] = await Promise.all([ - resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), - resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) - ]) - const issueOwnerRepo = issueResolved.source - const prOwnerRepo = prResolved.source - const ownerRepo = prOwnerRepo ?? issueOwnerRepo - if (!ownerRepo) { - return 0 - } - - const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null - const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - - // Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached). - if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { - await getRateLimit() - } - if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { - return 0 - } - - await acquire() - try { - if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) { - return await countWorkItemsForQuery( - repoPath, - ownerRepo, - effectiveQuery, - connectionId, - localGitOptions - ) - } - - const counts: Promise[] = [] - // Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call. - const hasPrOnlyFilter = - effectiveQuery.draft || - effectiveQuery.reviewRequested !== null || - effectiveQuery.reviewedBy !== null - if ( - effectiveQuery.scope !== 'pr' && - effectiveQuery.state !== 'merged' && - !hasPrOnlyFilter && - issueOwnerRepo - ) { - counts.push( - countWorkItemsForQuery( - repoPath, - issueOwnerRepo, - { ...effectiveQuery, scope: 'issue' }, - connectionId, - localGitOptions - ) - ) - } - if (effectiveQuery.scope !== 'issue' && prOwnerRepo) { - counts.push( - countWorkItemsForQuery( - repoPath, - prOwnerRepo, - { ...effectiveQuery, scope: 'pr' }, - connectionId, - localGitOptions - ) - ) - } - // Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves. - const results = await Promise.allSettled(counts) - let total = 0 - for (const r of results) { - if (r.status === 'fulfilled') { - total += r.value - } else { - console.warn('countWorkItems partial failure:', r.reason) - } - } - return total - } catch (err) { - console.warn('countWorkItems failed:', err) - return 0 - } finally { - release() - } -} - -export async function getRepoSlug( - repoPath: string, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - return getOriginGitHubApiRepository( - repoPath, - connectionId, - getHostedReviewLocalGitOptions(options) - ) -} - -/** - * Resolve a fork's upstream/parent owner/repo, or null when not a fork. - * Why: drives the fork indicator, and a same-name fork's avatar prefers the - * upstream owner (a renamed fork keeps its own owner). - * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null. - */ -export async function getRepoUpstream( - repoPath: string, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - undefined, - connectionId, - localGitOptions - ) - if (!origin) { - return null - } - const upstreamRemote = await getGitHubApiRepositoryForRemote( - repoPath, - 'upstream', - connectionId, - localGitOptions - ) - if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) { - return upstreamRemote - } - await acquire() - try { - // Why: positional slugs bypass the runner's --repo qualifier, so the slug - // itself must carry the Enterprise host. - const { stdout } = await ghExecFileAsync( - ['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'], - // Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation. - { - ...ghOptions, - timeout: 10_000 - } - ) - const data = JSON.parse(stdout) as { - isFork?: boolean - parent?: { name?: string; owner?: { login?: string } } | null - } - const owner = data.parent?.owner?.login - const repo = data.parent?.name - // Why: a fork parent lives on the same server as the fork. - return data.isFork && owner && repo - ? { owner, repo, ...(origin.host ? { host: origin.host } : {}) } - : null - } catch { - return null - } finally { - release() - } -} - -function classifyCreatePRError(error: unknown): CreateHostedReviewResult { - const { stderr, stdout } = extractExecError(error) - const message = `${stderr}\n${stdout}`.trim() - if (message) { - console.warn('createGitHubPullRequest failed:', message) - } - const lower = message.toLowerCase() - if ( - lower.includes('not logged') || - lower.includes('not authenticated') || - lower.includes('authentication') || - lower.includes('gh auth login') || - lower.includes('http 401') - ) { - return { - ok: false, - code: 'auth_required', - error: - 'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.' - } - } - if (lower.includes('already exists') || lower.includes('a pull request already exists')) { - return { - ok: false, - code: 'already_exists', - error: 'A pull request already exists for this branch.' - } - } - if (lower.includes('timed out') || lower.includes('timeout')) { - return { - ok: false, - code: 'unknown_completion', - error: 'PR creation may have completed. Refreshing branch review state...' - } - } - if (lower.includes('validation failed') || lower.includes('http 422')) { - return { - ok: false, - code: 'validation', - error: - 'Create PR failed: GitHub rejected the pull request. Check the base branch and branch state, then try again.' - } - } - return { - ok: false, - code: 'unknown', - error: 'Create PR failed: GitHub could not create the pull request. Try again in a moment.' - } -} - -function parseCreatePRPayload(stdout: string): { number: number; url: string } | null { - const trimmed = stdout.trim() - if (!trimmed) { - return null - } - try { - const parsed = JSON.parse(trimmed) as { number?: unknown; url?: unknown } - const number = Number(parsed.number) - const url = typeof parsed.url === 'string' ? parsed.url.trim() : '' - if (Number.isInteger(number) && number > 0 && url) { - return { number, url } - } - } catch { - // Fall through to URL parsing for older gh versions without --json support. - } - // Why: match any host (not just github.com) so a GHES PR URL still parses (#8312). - const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) - if (!urlMatch) { - return null - } - return { number: Number(urlMatch[1]), url: urlMatch[0] } -} - -async function findOpenPRByHeadBase(args: { - repoPath: string - repo: GitHubApiRepository - head: string - base: string - connectionId?: string | null - options?: HostedReviewExecutionOptions -}): Promise<{ number: number; url: string } | null> { - const context = githubRepoContext(args.repoPath, args.connectionId) - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'list', - '--repo', - `${args.repo.owner}/${args.repo.repo}`, - '--head', - args.head, - '--base', - args.base, - '--state', - 'open', - '--limit', - '2', - '--json', - 'number,url' - ], - { - ...ghRepoExecOptions(context), - ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)), - ...githubHostExecOptions(args.repo) - } - ) - const list = JSON.parse(stdout) as { number?: number; url?: string }[] - if (list.length !== 1 || !list[0]?.number || !list[0]?.url) { - return null - } - return { number: list[0].number, url: list[0].url } -} - -async function readPullRequestTemplate( - repoPath: string, - connectionId?: string | null -): Promise { - const relativeCandidates = [ - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - 'pull_request_template.md', - 'PULL_REQUEST_TEMPLATE.md', - 'docs/pull_request_template.md', - 'docs/PULL_REQUEST_TEMPLATE.md' - ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { - return '' - } - for (const relativeCandidate of relativeCandidates) { - try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } - return await readFile(join(repoPath, relativeCandidate), 'utf8') - } catch { - // Try the next conventional PR template path. - } - } - return '' -} - -export async function createGitHubPullRequest( - repoPath: string, - input: CreateHostedReviewInput, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - if (input.provider !== 'github') { - return { - ok: false, - code: 'unsupported_provider', - error: 'Creating reviews for this provider is not supported yet.' - } - } - - // Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312). - const ownerRepo = await getOriginGitHubApiRepository( - repoPath, - connectionId, - getHostedReviewLocalGitOptions(options) - ) - if (!ownerRepo) { - return { - ok: false, - code: 'unsupported_provider', - error: 'Creating pull requests requires a GitHub remote.' - } - } - // The runner host-qualifies --repo from options.host for GHES (#8312). - const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}` - - const base = normalizeHostedReviewBaseRef(input.base) - const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined - const title = input.title.trim() - if (!base || !title) { - return { - ok: false, - code: 'validation', - error: 'Create PR failed: base branch and title are required.' - } - } - if (head && head.toLowerCase() === base.toLowerCase()) { - return { - ok: false, - code: 'validation', - error: 'Create PR failed: choose a different base branch before creating a pull request.' - } - } - - const tempDir = await mkdtemp(join(tmpdir(), 'orca-pr-body-')) - await acquire() - const bodyPath = join(tempDir, 'body.md') - try { - const body = - input.useTemplate && !input.body?.trim() - ? await readPullRequestTemplate(repoPath, connectionId) - : (input.body ?? '') - await writeFile(bodyPath, body, 'utf8') - const createArgs = [ - 'pr', - 'create', - '--repo', - repoArg, - '--base', - base, - '--title', - title, - '--body-file', - bodyPath - ] - if (head) { - createArgs.push('--head', head) - } - if (input.draft) { - createArgs.push('--draft') - } - try { - const context = githubRepoContext(repoPath, connectionId) - const { stdout } = await ghExecFileAsync(createArgs, { - ...ghRepoExecOptions(context), - ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), - ...githubHostExecOptions(ownerRepo), - timeout: 60_000, - idempotent: false - }) - const created = parseCreatePRPayload(stdout) - if (created) { - return { ok: true, ...created } - } - const found = head - ? await findOpenPRByHeadBase({ - repoPath, - repo: ownerRepo, - head, - base, - connectionId, - options - }).catch(() => null) - : null - if (found) { - return { ok: true, ...found } - } - return { - ok: false, - code: 'unknown_completion', - error: 'PR creation may have completed. Refreshing branch review state...' - } - } catch (error) { - const classified = classifyCreatePRError(error) - if ( - !classified.ok && - (classified.code === 'already_exists' || classified.code === 'unknown_completion') && - head - ) { - const existing = await findOpenPRByHeadBase({ - repoPath, - repo: ownerRepo, - head, - base, - connectionId, - options - }).catch(() => null) - if (existing) { - return { - ok: false, - code: 'already_exists', - error: 'A pull request already exists for this branch.', - existingReview: existing - } - } - } - return classified - } - } finally { - await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) - release() - } -} - -export async function getWorkItem( - repoPath: string, - number: number, - type?: 'issue' | 'pr', - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {}, - preference?: IssueSourcePreference -): Promise { - await acquire() - try { - // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number - // must share that preference so origin/upstream toggles cannot disagree. - if (type === 'issue') { - const { source } = await resolveIssueGitHubApiRepositorySource( - repoPath, - preference, - connectionId, - localGitOptions - ) - // Why: explicit origin with no origin identity must not bare-lookup ambient gh - // (same fail-closed rule as origin-pinned PR candidate resolution). - if (!source && preference === 'origin') { - return null - } - return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions) - } - if (type === 'pr') { - return await fetchPullRequestWorkItemFromCandidates( - repoPath, - number, - connectionId, - localGitOptions, - preference - ) - } - - try { - const { source } = await resolveIssueGitHubApiRepositorySource( - repoPath, - preference, - connectionId, - localGitOptions - ) - if (source || preference !== 'origin') { - const issue = await fetchIssueWorkItem( - repoPath, - source, - number, - connectionId, - localGitOptions - ) - if (issue) { - return issue - } - } - } catch (err) { - // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR. - const stderr = err instanceof Error ? err.message : String(err) - if (classifyGhError(stderr).type !== 'not_found') { - throw err - } - } - return await fetchPullRequestWorkItemFromCandidates( - repoPath, - number, - connectionId, - localGitOptions, - preference - ) - } catch { - return null - } finally { - release() - } -} - -export async function getWorkItemByOwnerRepo( - repoPath: string, - ownerRepo: GitHubApiRepository, - number: number, - type: 'issue' | 'pr', - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const requestedHost = ownerRepo.host?.trim().toLowerCase() - const requestedRepository = requestedHost - ? { ...ownerRepo, host: requestedHost } - : await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions) - if (!requestedRepository) { - return null - } - const { candidates } = await resolveGitHubApiRepositoryCandidates( - repoPath, - connectionId, - localGitOptions - ) - const requestedKey = githubRepoIdentityKey(requestedRepository) - const matchedRepository = candidates.find( - (candidate) => githubRepoIdentityKey(candidate) === requestedKey - ) - // Why: this lookup is reachable from pasted links. Restricting it to a - // configured remote prevents gh from sending credentials to an arbitrary host. - if (!matchedRepository) { - return null - } - await acquire() - try { - if (type === 'issue') { - return await fetchIssueWorkItem( - repoPath, - matchedRepository, - number, - connectionId, - localGitOptions - ) - } - return await fetchPullRequestWorkItem( - repoPath, - matchedRepository, - number, - connectionId, - localGitOptions - ) - } catch { - return null - } finally { - release() - } -} - -type PullRequestLookupData = { - number: number - title: string - state: string - url: string - statusCheckRollup: unknown[] - updatedAt: string - isDraft?: boolean - mergeable: string - reviewDecision?: PRReviewDecision | null - autoMergeRequest?: unknown - autoMergeEnabled?: boolean - autoMergeAllowed?: boolean | null - mergeQueueRequired?: boolean | null - mergeMethodSettings?: GitHubPRMergeMethodSettings - mergeStateStatus?: string | null - baseRefName?: string - headRefName?: string - baseRefOid?: string - headRefOid?: string - stack?: GitHubPRStack - stackMetadataChecked?: boolean -} - -type RestPullRequest = { - number: number - title: string - state: string - html_url?: string - url?: string - updated_at?: string - draft?: boolean - merged_at?: string | null - mergeable?: boolean | null - mergeable_state?: string | null - base?: { ref?: string; sha?: string } - head?: { ref?: string; sha?: string } - stack?: { - number?: number - position?: number - size?: number - base?: { ref?: string; sha?: string } - } | null -} - -const PR_LOOKUP_JSON_FIELDS = - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' -const PR_BRANCH_LIST_JSON_FIELDS = - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' -const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName' -const GITHUB_AUTO_MERGE_METHODS: Record = { - merge: 'MERGE', - squash: 'SQUASH', - rebase: 'REBASE' -} - -export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & { - acceptMergedFallbackPR?: boolean - // Why: compare merged implicit PRs against the worktree HEAD, not main repo HEAD, without a worktree-scoped git call. - currentHeadOid?: string | null -} - -function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState { - const mergeableState = pr.mergeable_state?.toLowerCase() - if (mergeableState === 'dirty') { - return 'CONFLICTING' - } - if (mergeableState === 'clean' || pr.mergeable === true) { - return 'MERGEABLE' - } - return 'UNKNOWN' -} - -function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableState { - const mergeable = normalizePRMergeable(data.mergeable) - if (mergeable === 'CONFLICTING' || data.mergeStateStatus === 'DIRTY') { - return 'CONFLICTING' - } - return mergeable ?? 'UNKNOWN' -} - -function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { - const stack = - typeof pr.stack?.number === 'number' && - typeof pr.stack.position === 'number' && - typeof pr.stack.size === 'number' && - typeof pr.stack.base?.ref === 'string' - ? { - number: pr.stack.number, - position: pr.stack.position, - size: pr.stack.size, - baseRefName: pr.stack.base.ref, - ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {}) - } - : undefined - return { - number: pr.number, - title: pr.title, - state: pr.merged_at ? 'MERGED' : pr.state, - url: pr.html_url ?? pr.url ?? '', - statusCheckRollup: [], - updatedAt: pr.updated_at ?? '', - isDraft: pr.draft, - mergeable: mapRestPRMergeable(pr), - baseRefName: pr.base?.ref, - headRefName: pr.head?.ref, - baseRefOid: pr.base?.sha, - headRefOid: pr.head?.sha, - stackMetadataChecked: true, - ...(stack ? { stack } : {}) - } -} - -function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean { - // Why: a merged PR without an explicit link is just a historical branch match, not implicit review context. - return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged' -} - -async function getCurrentHeadOid( - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} -): Promise { - const provider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - if (provider) { - const result = await provider.exec(['rev-parse', 'HEAD'], repoPath) - return result.stdout.trim() || null - } - try { - const result = await gitExecFileAsync(['rev-parse', 'HEAD'], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return result.stdout.trim() || null - } catch { - return null - } -} - -function shouldHideMergedImplicitPR( - data: PullRequestLookupData | null, - linkedPRNumber: number | null | undefined, - currentHeadOid: string | null -): boolean { - if (!data || !isMergedImplicitPR(data, linkedPRNumber)) { - return false - } - // Why: keep hiding historical merged branch matches, but preserve the merged PR for the exact commit currently checked out. - return !currentHeadOid || data.headRefOid !== currentHeadOid -} - -function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData { - return { - ...data, - reviewDecision: - data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined, - autoMergeEnabled: - data.autoMergeEnabled ?? - ('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined) - } -} - -function cacheRepositoryMergeMetadata( - cacheKey: string, - value: GitHubRepositoryMergeMetadata, - ttlMs: number -): void { - const now = Date.now() - pruneRepositoryMergeMetadataCache(now) - // Why: merge metadata is keyed by user-controlled branch names; keep the cache bounded across many short-lived branches. - repositoryMergeMetadataCache.delete(cacheKey) - repositoryMergeMetadataCache.set(cacheKey, { - value, - expiresAt: now + ttlMs - }) - pruneRepositoryMergeMetadataCache(now) -} - -async function detectRepositoryMergeMetadata( - ownerRepo: GitHubApiRepository, - branchName: string | undefined, - ghOptions: GhExecOptions, - executionScope = 'default' -): Promise { - const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` - pruneRepositoryMergeMetadataCache() - const cached = repositoryMergeMetadataCache.get(cacheKey) - if (cached) { - return cached.value - } - const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) - if (guard.blocked) { - return { mergeQueueRequired: null, autoMergeAllowed: null } - } - const query = branchName - ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) { - repository(owner: $owner, name: $repo) { - viewerDefaultMergeMethod - mergeCommitAllowed - rebaseMergeAllowed - squashMergeAllowed - autoMergeAllowed - mergeQueue(branch: $branch) { id } - ref(qualifiedName: $qualified) { - rules(first: 50) { nodes { type } } - } - } - }` - : `query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - viewerDefaultMergeMethod - mergeCommitAllowed - rebaseMergeAllowed - squashMergeAllowed - autoMergeAllowed - } - }` - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const args = [ - 'api', - 'graphql', - '-f', - `query=${query}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}` - ] - if (branchName) { - args.push('-f', `branch=${branchName}`) - args.push('-f', `qualified=refs/heads/${branchName}`) - } - const { stdout } = await ghExecFileAsync(args, { - ...ghOptions, - ...githubHostExecOptions(ownerRepo) - }) - const parsed = JSON.parse(stdout) as { - data?: { - repository?: { - viewerDefaultMergeMethod?: unknown - mergeCommitAllowed?: unknown - rebaseMergeAllowed?: unknown - squashMergeAllowed?: unknown - autoMergeAllowed?: unknown - mergeQueue?: { id?: unknown } | null - ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null - } | null - } - } - const repository = parsed.data?.repository - const mergeMethodSettings = repository - ? normalizeGitHubPRMergeMethodSettings({ - defaultMethod: repository.viewerDefaultMergeMethod, - mergeCommitAllowed: repository.mergeCommitAllowed, - rebaseMergeAllowed: repository.rebaseMergeAllowed, - squashMergeAllowed: repository.squashMergeAllowed - }) - : undefined - const value: GitHubRepositoryMergeMetadata = { - mergeQueueRequired: branchName - ? Boolean(repository?.mergeQueue) || - Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE')) - : null, - autoMergeAllowed: - typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, - ...(mergeMethodSettings ? { mergeMethodSettings } : {}) - } - cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS) - return value - } catch { - // Why: cache a conservative result for failed merge-queue probes so we don't retry GraphQL on every poll while GitHub/network is unhappy. - const value: GitHubRepositoryMergeMetadata = { - mergeQueueRequired: null, - autoMergeAllowed: null - } - cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) - return value - } -} - -async function hydratePullRequestLookupData( - ownerRepo: OwnerRepo, - data: PullRequestLookupData, - ghOptions: GhExecOptions, - executionScope: string -): Promise { - const normalized = normalizePullRequestLookupData(data) - const hasRichMergeFields = - 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data - const mergeMetadata = hasRichMergeFields - ? await detectRepositoryMergeMetadata( - ownerRepo, - normalized.stack?.baseRefName ?? normalized.baseRefName, - ghOptions, - executionScope - ) - : undefined - return { - ...normalized, - ...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}), - ...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}), - ...(mergeMetadata?.mergeMethodSettings - ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) - } -} - -async function hydrateBranchLookupWithExactPR( - ownerRepo: OwnerRepo, - branchData: PullRequestLookupData | null, - ghOptions: GhExecOptions, - executionScope: string -): Promise { - if (!branchData) { - return null - } - try { - return ( - (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? - branchData - ) - } catch { - return branchData - } -} - -async function getRestPRForBranch( - prRepo: GitHubApiRepository, - headOwner: string, - branchName: string, - ghOptions: ReturnType -): Promise { - const head = encodeURIComponent(`${headOwner}:${branchName}`) - const { stdout } = await ghExecFileAsync( - ['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`], - { ...ghOptions, ...githubHostExecOptions(prRepo) } - ) - const list = JSON.parse(stdout) as RestPullRequest[] - const pr = list[0] - return pr ? mapRestPullRequest(pr) : null -} - -async function getFallbackPRListForBranch( - prRepo: GitHubApiRepository, - branchName: string, - ghOptions: ReturnType -): Promise { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'list', - '--repo', - `${prRepo.owner}/${prRepo.repo}`, - '--head', - branchName, - '--state', - 'all', - '--limit', - '1', - '--json', - PR_BRANCH_LIST_JSON_FIELDS - ], - { ...ghOptions, ...githubHostExecOptions(prRepo) } - ) - const list = JSON.parse(stdout) as PullRequestLookupData[] - return list[0] ?? null -} - -type TrackedUpstreamBranch = { - remoteName: string - branchName: string -} - -const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 -const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 - -type TrackedUpstreamSnapshotCacheEntry = { - expiresAt: number - gitConfigSignature?: string - upstreamsByBranchName: Map -} - -type TrackedUpstreamSnapshotProbeResult = { - cacheable: boolean - gitConfigSignature?: string - probeFailed: boolean - upstreamsByBranchName: Map -} - -const trackedUpstreamSnapshotCache = new Map() -const trackedUpstreamSnapshotInFlight = new Map< - string, - Promise ->() -const trackedUpstreamSnapshotGenerations = new Map() - -function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { - const generation = Symbol() - trackedUpstreamSnapshotGenerations.set(cacheKey, generation) - return generation -} - -function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { - // Why: generations only guard an active probe; retaining completed keys leaks worktree/runtime identities past the snapshot TTL. - if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { - trackedUpstreamSnapshotGenerations.delete(cacheKey) - } -} - -function pruneTrackedUpstreamSnapshotCache(now: number): void { - for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { - if (cached.expiresAt <= now) { - trackedUpstreamSnapshotCache.delete(cacheKey) - } - } - // Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound. - while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { - const oldestKey = trackedUpstreamSnapshotCache.keys().next().value - if (oldestKey === undefined) { - break - } - trackedUpstreamSnapshotCache.delete(oldestKey) - } -} - -export function _getTrackedUpstreamBranchCacheSizesForTests(): { - snapshots: number - inFlight: number - generations: number -} { - return { - snapshots: trackedUpstreamSnapshotCache.size, - inFlight: trackedUpstreamSnapshotInFlight.size, - generations: trackedUpstreamSnapshotGenerations.size - } -} - -export function __resetTrackedUpstreamBranchCacheForTests(): void { - trackedUpstreamSnapshotCache.clear() - trackedUpstreamSnapshotInFlight.clear() - trackedUpstreamSnapshotGenerations.clear() -} - -function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null { - const parsed = splitRemoteBranchName(upstreamRef.trim()) - if (!parsed) { - return null - } - return parsed -} - -function shouldRetryTrackedUpstreamBranch( - upstreamBranch: TrackedUpstreamBranch, - branchName: string, - upstreamHeadRepo: OwnerRepo, - headRepo: OwnerRepo | null -): boolean { - if (upstreamBranch.branchName !== branchName) { - return true - } - if (!headRepo) { - return true - } - return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo) -} - -async function getTrackedUpstreamBranch( - repoPath: string, - branchName: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} -): Promise { - const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions) - const now = Date.now() - const cached = trackedUpstreamSnapshotCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch( - cached, - repoPath, - connectionId, - localGitOptions - ) - if ( - configSignatureMatches && - cached.upstreamsByBranchName.has(branchName) && - canUseCachedTrackedUpstreamBranch(cached, branchName) - ) { - return cached.upstreamsByBranchName.get(branchName) ?? null - } - trackedUpstreamSnapshotCache.delete(cacheKey) - } - if (cached) { - trackedUpstreamSnapshotCache.delete(cacheKey) - } - - const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) - if (inFlight) { - const result = await inFlight - if (result.upstreamsByBranchName.has(branchName)) { - return result.upstreamsByBranchName.get(branchName) ?? null - } - // Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null. - const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) - if (retryInFlight) { - const retryResult = await retryInFlight - return retryResult.upstreamsByBranchName.get(branchName) ?? null - } - } - - // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch. - const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey) - const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions) - trackedUpstreamSnapshotInFlight.set(cacheKey, probe) - try { - const result = await probe - if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) { - trackedUpstreamSnapshotCache.set(cacheKey, { - ...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}), - upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), - expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS - }) - pruneTrackedUpstreamSnapshotCache(Date.now()) - } - if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { - const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey) - if (fresherCached?.upstreamsByBranchName.has(branchName)) { - return fresherCached.upstreamsByBranchName.get(branchName) ?? null - } - } - return result.upstreamsByBranchName.get(branchName) ?? null - } finally { - if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { - trackedUpstreamSnapshotInFlight.delete(cacheKey) - } - finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) - } -} - -async function probeTrackedUpstreamSnapshot( - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} -): Promise { - const startingGitConfigSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: connectionId ?? null, - ...localGitOptions - }) - const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches( - repoPath, - connectionId, - localGitOptions - ) - const endingGitConfigSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: connectionId ?? null, - ...localGitOptions - }) - const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro - const configSignatureChanged = - isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature - const gitConfigSignature = - startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined - return { - // Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick. - cacheable: !configSignatureChanged && !probeFailed, - probeFailed, - ...(gitConfigSignature ? { gitConfigSignature } : {}), - upstreamsByBranchName - } -} - -function getCacheableTrackedUpstreamSnapshot( - upstreamsByBranchName: Map -): Map { - // Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan. - return upstreamsByBranchName -} - -function canUseCachedTrackedUpstreamBranch( - cached: TrackedUpstreamSnapshotCacheEntry, - branchName: string -): boolean { - return cached.upstreamsByBranchName.has(branchName) -} - -async function doesTrackedUpstreamCacheConfigSignatureMatch( - cached: TrackedUpstreamSnapshotCacheEntry, - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} -): Promise { - if (!cached.gitConfigSignature) { - return true - } - const currentSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: connectionId ?? null, - ...localGitOptions - }) - return currentSignature === cached.gitConfigSignature -} - -function getTrackedUpstreamBranchCacheKey( - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} -): string { - const runtimeKey = connectionId - ? `ssh:${connectionId}` - : `local:${localGitOptions.wslDistro ?? 'host'}` - return [runtimeKey, repoPath].join('\0') -} - -async function probeTrackedUpstreamBranches( - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} -): Promise<{ - probeFailed: boolean - upstreamsByBranchName: Map -}> { - const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'] - const provider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - if (provider) { - const result = await provider.exec(args, repoPath) - return { - probeFailed: false, - upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) - } - } - try { - const result = await gitExecFileAsync(args, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return { - probeFailed: false, - upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) - } - } catch { - return { probeFailed: true, upstreamsByBranchName: new Map() } - } -} - -function parseTrackedUpstreamBranches(stdout: string): Map { - const upstreamsByBranchName = new Map() - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - const [branchName, upstreamRef] = line.split('\0') - const localBranchName = branchName?.replace(/^refs\/heads\//, '') - if (!localBranchName) { - continue - } - upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? '')) - } - return upstreamsByBranchName -} - -function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null { - const remoteRefPrefix = 'refs/remotes/' - const normalizedRef = upstreamRef.trim() - if (normalizedRef.startsWith(remoteRefPrefix)) { - return parseTrackedUpstreamBranch(normalizedRef.slice(remoteRefPrefix.length)) - } - if (normalizedRef.startsWith('refs/heads/')) { - return null - } - return parseTrackedUpstreamBranch(normalizedRef) -} - -async function lookupPRByBranchName(args: { - candidates: OwnerRepo[] - headRepo: OwnerRepo | null - branchName: string - ghOptions: GhExecOptions - executionScope: string -}): Promise<{ - data: PullRequestLookupData | null - dataRepo: OwnerRepo | null - pendingError?: unknown -}> { - if (args.candidates.length > 0) { - let pendingError: unknown - let hasPendingError = false - for (const candidate of args.candidates) { - try { - const branchData = args.headRepo - ? await getRestPRForBranch( - candidate, - args.headRepo.owner, - args.branchName, - args.ghOptions - ) - : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) - // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. - const data = await hydrateBranchLookupWithExactPR( - candidate, - branchData, - args.ghOptions, - args.executionScope - ) - if (data) { - return { data, dataRepo: candidate } - } - } catch (err) { - if (args.headRepo) { - throw err - } - if (!hasPendingError) { - pendingError = err - hasPendingError = true - } - try { - const branchData = await getRestPRForBranch( - candidate, - candidate.owner, - args.branchName, - args.ghOptions - ) - const data = await hydrateBranchLookupWithExactPR( - candidate, - branchData, - args.ghOptions, - args.executionScope - ) - if (data) { - return { data, dataRepo: candidate } - } - } catch (retryErr) { - if (!hasPendingError) { - pendingError = retryErr - hasPendingError = true - } - } - } - } - // Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error. - return hasPendingError - ? { data: null, dataRepo: null, pendingError } - : { data: null, dataRepo: null } - } - - try { - const { stdout } = await ghExecFileAsync( - ['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS], - args.ghOptions - ) - return { - data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), - dataRepo: null - } - } catch (err) { - if (isNoPullRequestError(err)) { - return { data: null, dataRepo: null } - } - throw err - } -} - -function isPositiveSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 -} - -function isGitObjectId(value: unknown): value is string { - return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) -} - -function isUsableRestStackMetadata(value: unknown): boolean { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const stack = value as { - number?: unknown - position?: unknown - size?: unknown - base?: unknown - } - if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) { - return false - } - const base = stack.base as { ref?: unknown; sha?: unknown } - return ( - isPositiveSafeInteger(stack.number) && - isPositiveSafeInteger(stack.position) && - isPositiveSafeInteger(stack.size) && - stack.position <= stack.size && - typeof base.ref === 'string' && - base.ref.trim().length > 0 && - (base.sha === undefined || isGitObjectId(base.sha)) - ) -} - -async function getRestPRByNumber( - ownerRepo: GitHubApiRepository, - number: number, - ghOptions: ReturnType, - options: { requireUsableStackMetadata?: boolean } = {} -): Promise { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - { ...ghOptions, ...githubHostExecOptions(ownerRepo) } - ) - const parsed = JSON.parse(stdout) as unknown - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('invalid response shape') - } - const restData = parsed as RestPullRequest - const mapped = mapRestPullRequest(restData) - if ( - options.requireUsableStackMetadata && - restData.stack !== undefined && - restData.stack !== null - ) { - // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe. - if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) { - throw new Error('malformed stack') - } - if (!isGitObjectId(restData.head?.sha)) { - throw new Error('missing head SHA') - } - } - return mapped -} - -function prunePRStackSummaryCache(now = Date.now()): void { - for (const [key, cached] of prStackSummaryCache) { - if (cached.expiresAt <= now) { - prStackSummaryCache.delete(key) - } - } - while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) { - const oldestKey = prStackSummaryCache.keys().next().value - if (oldestKey === undefined) { - return - } - prStackSummaryCache.delete(oldestKey) - } -} - -async function getCachedGitHubPRStackSummary( - ownerRepo: GitHubApiRepository, - number: number, - ghOptions: ReturnType, - executionScope: string -): Promise { - const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}` - const now = Date.now() - prunePRStackSummaryCache(now) - const cached = prStackSummaryCache.get(key) - if (cached && cached.expiresAt > now) { - return cached.value - } - const existing = prStackSummaryInFlight.get(key) - if (existing) { - return existing - } - const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr.stack) - prStackSummaryInFlight.set(key, request) - try { - const value = await request - prStackSummaryCache.delete(key) - prStackSummaryCache.set(key, { - value, - expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS - }) - prunePRStackSummaryCache() - return value - } catch (err) { - // Why: avoid repeating a failed REST probe on every review poll. - prStackSummaryCache.delete(key) - prStackSummaryCache.set(key, { - value: undefined, - expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS - }) - prunePRStackSummaryCache() - throw err - } finally { - if (prStackSummaryInFlight.get(key) === request) { - prStackSummaryInFlight.delete(key) - } - } -} - -async function getPRByNumber( - ownerRepo: GitHubApiRepository, - number: number, - ghOptions: ReturnType, - executionScope: string, - knownPullRequestData?: PullRequestLookupData | null -): Promise { - try { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - String(number), - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--json', - PR_LOOKUP_JSON_FIELDS - ], - { ...ghOptions, ...githubHostExecOptions(ownerRepo) } - ) - const exactData = JSON.parse(stdout) as PullRequestLookupData - return hydratePullRequestLookupData( - ownerRepo, - { - ...knownPullRequestData, - ...exactData, - ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) - }, - ghOptions, - executionScope - ) - } catch (err) { - // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. - if (isNotFoundGhError(err)) { - return null - } - try { - const restData = - knownPullRequestData === undefined - ? await getRestPRByNumber(ownerRepo, number, ghOptions) - : knownPullRequestData - return restData - ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) - : null - } catch (restErr) { - if (isNotFoundGhError(restErr)) { - return null - } - if (!shouldStopAfterExactLookupError(restErr)) { - return null - } - throw restErr - } - } -} - -async function lookupPRByNumber(args: { - candidates: OwnerRepo[] - number: number - ghOptions: ReturnType - executionScope: string -}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { - for (const candidate of args.candidates) { - try { - const linkedData = await getPRByNumber( - candidate, - args.number, - args.ghOptions, - args.executionScope - ) - if (!linkedData) { - continue - } - return { data: linkedData, dataRepo: candidate } - } catch (err) { - if (shouldStopAfterExactLookupError(err)) { - throw err - } - // Candidate probing is best-effort; another repo may own the PR. - } - } - - if (args.candidates.length > 0) { - return { data: null, dataRepo: null } - } - - try { - const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], - args.ghOptions - ) - return { - data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), - dataRepo: null - } - } catch (err) { - if (isNoPullRequestError(err)) { - // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo. - return { data: null, dataRepo: null } - } - throw err - } -} - -function isNotFoundGhError(err: unknown): boolean { - const stderr = err instanceof Error ? err.message : String(err) - return classifyGhError(stderr).type === 'not_found' -} - -function shouldStopAfterExactLookupError(err: unknown): boolean { - const stderr = err instanceof Error ? err.message : String(err) - const type = classifyGhError(stderr).type - return type !== 'not_found' -} - -/** - * Get PR info for a given branch using gh CLI. - * Returns null if gh is not installed, or no PR exists for the branch. - * - * When `linkedPRNumber` is provided, it is the source of truth. This handles - * "create from PR" worktrees whose local branch differs from the PR head ref, - * and prevents a coalesced linked-PR refresh from fanning out an unrelated - * branch lookup result to sibling aliases. - * `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is - * used only after branch lookup misses. - */ -export async function getPRForBranch( - repoPath: string, - branch: string, - linkedPRNumber?: number | null, - connectionId?: string | null, - fallbackPRNumber?: number | null, - options: GitHubPRBranchLookupOptions = {} -): Promise { - const outcome = await getPRForBranchOutcome( - repoPath, - branch, - linkedPRNumber, - connectionId, - fallbackPRNumber, - options - ) - return outcome.kind === 'found' ? outcome.pr : null -} - -// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks. -function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { - const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) - return match ? { owner: match[2], repo: match[3], host: match[1] } : null -} - -export async function getPRForBranchOutcome( - repoPath: string, - branch: string, - linkedPRNumber?: number | null, - connectionId?: string | null, - fallbackPRNumber?: number | null, - options: GitHubPRBranchLookupOptions = {} -): Promise { - const branchName = branch.replace(/^refs\/heads\//, '') - // Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible. - if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') { - return { kind: 'no-pr', fetchedAt: Date.now() } - } - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions) - - await acquire() - try { - const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates( - repoPath, - connectionId, - localGitOptions - ) - // Why: connection-backed gh runs without a repository cwd. A bare lookup - // here can honor process GH_REPO/GH_HOST and return an unrelated PR. - if (connectionId && candidates.length === 0) { - return { kind: 'no-pr', fetchedAt: Date.now() } - } - // Why (#11532): account every lookup, not just the coordinator's queue — - // `hostedReview:forBranch` reaches this directly from renderer polling and - // was spending the shared quota invisibly. headRepo is `origin`, the same - // identity the coordinator guards on. - for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) { - noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions) - } - let data: PullRequestLookupData | null = null - let dataRepo: OwnerRepo | null = null - let dataHeadRepo: OwnerRepo | null = headRepo - let pendingBranchLookupError: unknown - let hasPendingBranchLookupError = false - let currentHeadOidForMergedImplicit: string | null | undefined - let usedExactNumberLookup = false - - const explicitCurrentHeadOid = - typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 - ? options.currentHeadOid.trim() - : null - let confirmedContainedHeadOid: string | null = null - let headDivergedFromMergedPRAtOid: string | null = null - const mergedPRContainsHead = async ( - candidate: PullRequestLookupData, - candidateRepo: OwnerRepo | null, - headOid: string | null - ): Promise => { - if (!candidateRepo || !headOid) { - return 'unknown' - } - const membership = await isCommitPartOfMergedPR({ - ownerRepo: candidateRepo, - prNumber: candidate.number, - commitOid: headOid, - ghOptions - }) - if (membership === 'contained') { - confirmedContainedHeadOid = headOid - } - return membership - } - const recordLinkedMergedPRDivergence = async ( - candidate: PullRequestLookupData | null, - candidateRepo: OwnerRepo | null - ): Promise => { - if ( - typeof linkedPRNumber !== 'number' || - !candidate || - mapPRState(candidate.state, candidate.isDraft) !== 'merged' || - explicitCurrentHeadOid === null || - candidate.headRefOid === explicitCurrentHeadOid - ) { - return - } - const membership = await mergedPRContainsHead( - candidate, - candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url), - explicitCurrentHeadOid - ) - if (membership === 'not-contained') { - // explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree. - headDivergedFromMergedPRAtOid = explicitCurrentHeadOid - } - } - const hideMergedImplicitPR = async ( - candidate: PullRequestLookupData | null, - candidateRepo: OwnerRepo | null - ) => { - if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) { - return false - } - // Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees. - currentHeadOidForMergedImplicit ??= - explicitCurrentHeadOid !== null - ? explicitCurrentHeadOid - : await getCurrentHeadOid(repoPath, connectionId, localGitOptions) - if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) { - return false - } - // Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible. - return ( - (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !== - 'contained' - ) - } - - if (typeof linkedPRNumber === 'number') { - usedExactNumberLookup = true - const exactLookup = await lookupPRByNumber({ - candidates, - number: linkedPRNumber, - ghOptions, - executionScope - }) - data = exactLookup.data - dataRepo = exactLookup.dataRepo - } else if (branchName) { - // During a rebase (detached HEAD) branch is empty; an empty --head filter makes gh return an arbitrary PR. - const branchLookup = await lookupPRByBranchName({ - candidates, - headRepo, - branchName, - ghOptions, - executionScope - }) - data = branchLookup.data - dataRepo = branchLookup.dataRepo - if ('pendingError' in branchLookup) { - pendingBranchLookupError = branchLookup.pendingError - hasPendingBranchLookupError = true - } - if (!data) { - // Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match. - const upstreamBranch = await getTrackedUpstreamBranch( - repoPath, - branchName, - connectionId, - localGitOptions - ) - if (upstreamBranch) { - const upstreamHeadRepo = - (await getGitHubApiRepositoryForRemote( - repoPath, - upstreamBranch.remoteName, - connectionId, - localGitOptions - )) ?? headRepo - if ( - upstreamHeadRepo && - shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo) - ) { - const upstreamLookup = await lookupPRByBranchName({ - candidates, - headRepo: upstreamHeadRepo, - branchName: upstreamBranch.branchName, - ghOptions, - executionScope - }) - data = upstreamLookup.data - dataRepo = upstreamLookup.dataRepo - if (!hasPendingBranchLookupError && 'pendingError' in upstreamLookup) { - pendingBranchLookupError = upstreamLookup.pendingError - hasPendingBranchLookupError = true - } - if (data) { - dataHeadRepo = upstreamHeadRepo - } - } - } - } - } - let mergedBranchLookupNumber: number | null = null - if (await hideMergedImplicitPR(data, dataRepo)) { - mergedBranchLookupNumber = data?.number ?? null - data = null - dataRepo = null - dataHeadRepo = headRepo - } - if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { - usedExactNumberLookup = true - const fallbackLookup = await lookupPRByNumber({ - candidates, - number: fallbackPRNumber, - ghOptions, - executionScope - }) - data = fallbackLookup.data - dataRepo = fallbackLookup.dataRepo - } - if (!data) { - if (hasPendingBranchLookupError) { - return prRefreshUpstreamError(pendingBranchLookupError) - } - return { kind: 'no-pr', fetchedAt: Date.now() } - } - await recordLinkedMergedPRDivergence(data, dataRepo) - const fallbackConfirmedMergedBranch = - typeof fallbackPRNumber === 'number' && - mergedBranchLookupNumber === fallbackPRNumber && - data.number === fallbackPRNumber - const explicitHeadHidesMergedImplicitPR = - explicitCurrentHeadOid !== null && - shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && - (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained' - // Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs. - const shouldPreserveMergedFallback = - !explicitHeadHidesMergedImplicitPR && - (fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true) - // Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads). - if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) { - return { kind: 'no-pr', fetchedAt: Date.now() } - } - // Why (#9171): on the default branch an implicit branch/fallback match must - // never surface a non-open PR — it overrides the merged-fallback - // preservation and merged-at-head carve-out on the trunk only. An exact - // linked lookup returns the linked number, so linked PRs are exempt. - if ( - await shouldHideNonOpenReviewOnDefaultBranch({ - state: mapPRState(data.state, data.isDraft), - reviewNumber: data.number, - linkedReviewNumber: linkedPRNumber, - branchName, - repoPath, - connectionId, - localGitOptions - }) - ) { - return { kind: 'no-pr', fetchedAt: Date.now() } - } - - if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) { - try { - data.stack = await getCachedGitHubPRStackSummary( - dataRepo, - data.number, - ghOptions, - executionScope - ) - data.stackMetadataChecked = true - } catch { - // Stack metadata is additive; exact PR lookup remains usable without it. - } - } - const mergeable = derivePullRequestMergeable(data) - const stack = - data.stack && dataRepo - ? await hydrateGitHubPRStack( - dataRepo, - data.number, - data.stack, - ghOptions, - data.updatedAt, - executionScope - ) - : data.stack - const stackMergeQueueRequired = - stack && dataRepo - ? ( - await detectRepositoryMergeMetadata( - dataRepo, - stack.baseRefName, - ghOptions, - executionScope - ) - ).mergeQueueRequired - : undefined - const conflictSummary = - !connectionId && - mergeable === 'CONFLICTING' && - data.baseRefName && - data.baseRefOid && - data.headRefOid - ? await getPRConflictSummary( - repoPath, - data.baseRefName, - data.baseRefOid, - data.headRefOid, - localGitOptions - ) - : undefined - - return { - kind: 'found', - fetchedAt: Date.now(), - pr: { - number: data.number, - title: data.title, - state: mapPRState(data.state, data.isDraft), - url: data.url, - checksStatus: deriveCheckStatus(data.statusCheckRollup), - updatedAt: data.updatedAt, - mergeable, - ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), - ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), - ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}), - ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined - ? { - mergeQueueRequired: - stackMergeQueueRequired !== undefined - ? stackMergeQueueRequired - : data.mergeQueueRequired - } - : {}), - ...(data.mergeMethodSettings !== undefined - ? { mergeMethodSettings: data.mergeMethodSettings } - : {}), - ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), - ...(stack ? { stack } : {}), - headSha: data.headRefOid, - ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), - ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), - ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}), - ...(data.headRefName ? { headRefName: data.headRefName } : {}), - prRepo: dataRepo ?? undefined, - headRepo: dataHeadRepo ?? undefined, - conflictSummary - } - } - } catch (err) { - return prRefreshUpstreamError(err) - } finally { - release() - } -} - -const PR_CHECKS_ROLLUP_QUERY = ` -query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - headRefOid - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - contexts(first: 100) { - nodes { - __typename - ... on CheckRun { - databaseId - name - status - conclusion - detailsUrl - url - checkSuite { - databaseId - workflowRun { - databaseId - } - } - } - ... on StatusContext { - context - state - targetUrl - } - } - } - } - checkSuites(first: 100) { - nodes { - databaseId - status - conclusion - url - app { - name - slug - } - } - } - } - } - } - } - } -} -` - -type GraphQLPRChecksResponse = { - data?: { - repository?: { - pullRequest?: { - headRefOid?: string | null - commits?: { - nodes?: { commit?: GraphQLPRChecksCommit | null }[] | null - } | null - } | null - } | null - } | null -} - -type GraphQLPRChecksCommit = { - statusCheckRollup?: { - contexts?: { - nodes?: GraphQLStatusCheckContext[] | null - } | null - } | null - checkSuites?: { - nodes?: GraphQLCheckSuite[] | null - } | null -} - -type GraphQLCheckRunContext = { - __typename: 'CheckRun' - databaseId?: number | null - name?: string | null - status?: string | null - conclusion?: string | null - detailsUrl?: string | null - url?: string | null - checkSuite?: { - databaseId?: number | null - workflowRun?: { databaseId?: number | null } | null - } | null -} - -type GraphQLStatusContext = { - __typename: 'StatusContext' - context?: string | null - state?: string | null - targetUrl?: string | null -} - -type GraphQLStatusCheckContext = - | GraphQLCheckRunContext - | GraphQLStatusContext - | { __typename?: string | null } - -type GraphQLCheckSuite = { - databaseId?: number | null - status?: string | null - conclusion?: string | null - url?: string | null - app?: { name?: string | null; slug?: string | null } | null -} - -type RestCheckRun = { - id?: number - name: string - status: string - conclusion: string | null - html_url: string - details_url: string | null -} - -type RestCommitStatus = { - context?: string - state?: string - target_url?: string | null -} - -type RestCheckSuite = { - id?: number | null - status: string | null - conclusion: string | null - app?: { name?: string | null; slug?: string | null } | null -} - -function isGraphQLCheckRunContext( - context: GraphQLStatusCheckContext -): context is GraphQLCheckRunContext { - return context.__typename === 'CheckRun' -} - -function isGraphQLStatusContext( - context: GraphQLStatusCheckContext -): context is GraphQLStatusContext { - return context.__typename === 'StatusContext' -} - -function mapGraphQLCheckRunContext(context: GraphQLCheckRunContext): PRCheckDetail | null { - const name = nullableString(context.name) - if (!name) { - return null - } - const url = nullableString(context.detailsUrl) ?? nullableString(context.url) - const checkRunId = nullableNumber(context.databaseId) - const workflowRunId = - nullableNumber(context.checkSuite?.workflowRun?.databaseId) ?? parseActionsRunId(url) - return { - name, - status: mapCheckRunRESTStatus(context.status ?? ''), - conclusion: mapCheckRunRESTConclusion(context.status ?? '', context.conclusion ?? null), - url, - ...(checkRunId !== null ? { checkRunId } : {}), - ...(typeof workflowRunId === 'number' ? { workflowRunId } : {}) - } -} - -function mapGraphQLStatusContext(context: GraphQLStatusContext): PRCheckDetail | null { - const name = nullableString(context.context) - if (!name) { - return null - } - const url = nullableString(context.targetUrl) - const workflowRunId = parseActionsRunId(url) - return { - name, - status: mapCommitStatusRESTStatus(context.state ?? ''), - conclusion: mapCommitStatusRESTConclusion(context.state ?? ''), - url, - ...(workflowRunId !== undefined ? { workflowRunId } : {}) - } -} - -function mapRestCheckRun(checkRun: RestCheckRun): PRCheckDetail { - return { - name: checkRun.name, - status: mapCheckRunRESTStatus(checkRun.status), - conclusion: mapCheckRunRESTConclusion(checkRun.status, checkRun.conclusion), - url: checkRun.details_url || checkRun.html_url || null, - ...(typeof checkRun.id === 'number' ? { checkRunId: checkRun.id } : {}), - workflowRunId: parseActionsRunId(checkRun.details_url || checkRun.html_url || null) - } -} - -function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null { - const name = nullableString(status.context) - if (!name) { - return null - } - const url = nullableString(status.target_url) - const workflowRunId = parseActionsRunId(url) - return { - name, - status: mapCommitStatusRESTStatus(status.state ?? ''), - conclusion: mapCommitStatusRESTConclusion(status.state ?? ''), - url, - ...(workflowRunId !== undefined ? { workflowRunId } : {}) - } -} - -function mapGraphQLPendingApprovalCheckSuite( - ownerRepo: GitHubApiRepository, - suite: GraphQLCheckSuite, - headSha: string | null | undefined, - index: number -): PRCheckDetail { - return { - name: getPendingApprovalCheckSuiteName(suite, headSha, index), - status: 'completed', - conclusion: 'action_required', - // Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one. - url: - nullableString(suite.url) ?? - (headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null) - } -} - -function mapGraphQLPRChecksResponse( - ownerRepo: GitHubApiRepository, - response: GraphQLPRChecksResponse -): PRCheckDetail[] | null { - const pullRequest = response.data?.repository?.pullRequest - if (!pullRequest) { - return null - } - const commit = pullRequest.commits?.nodes?.[0]?.commit - if (!commit) { - return [] - } - - const contexts = commit.statusCheckRollup?.contexts?.nodes ?? [] - const checkRunContexts = contexts.filter(isGraphQLCheckRunContext) - const checkRuns = checkRunContexts - .map(mapGraphQLCheckRunContext) - .filter((check): check is PRCheckDetail => check !== null) - const checkRunNames = new Set(checkRuns.map((check) => check.name)) - const checkSuiteIdsWithRuns = new Set( - checkRunContexts - .map((context) => nullableNumber(context.checkSuite?.databaseId)) - .filter((id): id is number => id !== null) - ) - // Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions. - const legacyStatuses = contexts - .filter(isGraphQLStatusContext) - .map(mapGraphQLStatusContext) - .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)) - const pendingApprovalChecks = (commit.checkSuites?.nodes ?? []) - .filter((suite) => suite.conclusion?.toLowerCase() === 'action_required') - .filter((suite) => { - const suiteId = nullableNumber(suite.databaseId) - return suiteId === null || !checkSuiteIdsWithRuns.has(suiteId) - }) - .map((suite, index) => - mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index) - ) - - return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks] -} - -async function getPRChecksViaRestFallback( - ownerRepo: GitHubApiRepository, - headSha: string | undefined, - ghOptions: GhExecOptions, - noCache?: boolean -): Promise { - if (!headSha) { - return null - } - try { - await assertRateLimitBudget('core', ownerRepo, ghOptions) - } catch (err) { - console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err) - return null - } - - await acquire() - try { - const cacheArgs = noCache ? [] : ['--cache', '60s'] - const encodedHeadSha = encodeURIComponent(headSha) - const { stdout } = await ghExecFileAsync( - [ - 'api', - ...cacheArgs, - `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-runs?per_page=100` - ], - ghOptions - ) - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const checkRunData = JSON.parse(stdout) as { - check_runs?: RestCheckRun[] - } - const checkRuns = (checkRunData.check_runs ?? []).map(mapRestCheckRun) - const checkRunNames = new Set(checkRuns.map((check) => check.name)) - - let legacyStatuses: PRCheckDetail[] = [] - try { - const statusResult = await ghExecFileAsync( - [ - 'api', - ...cacheArgs, - `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100` - ], - ghOptions - ) - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const statusData = JSON.parse(statusResult.stdout) as { - statuses?: RestCommitStatus[] - } - legacyStatuses = (statusData.statuses ?? []) - .map(mapRestCommitStatus) - .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)) - } catch (err) { - // Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails. - console.warn('getPRChecks REST status fallback failed:', err) - } - - let pendingApprovalChecks: PRCheckDetail[] = [] - try { - const suitesResult = await ghExecFileAsync( - [ - 'api', - ...cacheArgs, - `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-suites?per_page=100` - ], - ghOptions - ) - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const suitesData = JSON.parse(suitesResult.stdout) as { - check_suites?: RestCheckSuite[] - } - pendingApprovalChecks = (suitesData.check_suites ?? []) - .filter((suite) => suite.conclusion?.toLowerCase() === 'action_required') - .map((suite, index) => ({ - name: getPendingApprovalCheckSuiteName(suite, headSha, index), - status: 'completed' as const, - conclusion: 'action_required' as const, - url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id) - })) - } catch (err) { - console.warn('getPRChecks REST check-suite fallback failed:', err) - } - - const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks] - return checks.length > 0 ? checks : null - } catch (err) { - console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err) - return null - } finally { - release() - } -} - -/** - * Get detailed check statuses for a PR. - * Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses - * arrive in one cached request; suite-only approval blockers are included too. - */ -export async function getPRChecks( - repoPath: string, - prNumber: number, - headSha?: string, - prRepo?: GitHubApiRepository | null, - options?: { noCache?: boolean }, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - void headSha - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (connectionId && !ownerRepo) { - throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) - } - const fallbackToPRChecks = async (): Promise => { - await assertRateLimitBudget('graphql', ownerRepo, ghOptions) - await acquire() - try { - const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] - if (ownerRepo) { - fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => { - const { stderr } = extractExecError(err) - // Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure. - if (stderr.toLowerCase().includes('no checks reported')) { - return { stdout: '[]', stderr } - } - throw err - }) - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] - return data.map((d) => ({ - name: d.name, - status: mapCheckStatus(d.state), - conclusion: mapCheckConclusion(d.state), - url: d.link || null, - workflowRunId: parseActionsRunId(d.link) - })) - } finally { - release() - } - } - - if (ownerRepo) { - let canUseGraphQLRollup = true - try { - await assertRateLimitBudget('graphql', ownerRepo, ghOptions) - } catch (err) { - canUseGraphQLRollup = false - console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err) - } - if (canUseGraphQLRollup) { - await acquire() - try { - // Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data. - const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - ...cacheArgs, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `pr=${prNumber}`, - '-f', - `query=${PR_CHECKS_ROLLUP_QUERY}` - ], - ghOptions - ) - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const checks = mapGraphQLPRChecksResponse( - ownerRepo, - JSON.parse(stdout) as GraphQLPRChecksResponse - ) - if (checks !== null) { - return checks - } - } catch (err) { - // Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable. - console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err) - } finally { - release() - } - } - const restChecks = await getPRChecksViaRestFallback( - ownerRepo, - headSha, - ghOptions, - options?.noCache - ) - if (restChecks !== null) { - return restChecks - } - } - - try { - return await fallbackToPRChecks() - } catch (err) { - console.warn('getPRChecks failed:', err) - throw err - } -} - -function getPendingApprovalCheckSuiteName( - suite: { - id?: number | null - databaseId?: number | null - app?: { name?: string | null; slug?: string | null } | null - }, - headSha: string | null | undefined, - index: number -): string { - const appName = suite.app?.name ?? suite.app?.slug ?? null - const rawSuiteId = suite.databaseId ?? suite.id - const suiteId = - typeof rawSuiteId === 'number' && Number.isFinite(rawSuiteId) ? `#${rawSuiteId}` : null - if (appName && suiteId) { - return `${appName} ${suiteId}` - } - if (appName) { - return appName - } - if (suiteId) { - return suiteId - } - return `${headSha?.slice(0, 12) ?? 'check-suite'}:${index + 1}` -} - -function getPendingApprovalCheckSuiteUrl( - ownerRepo: GitHubApiRepository, - headSha: string, - suiteId: number | null | undefined -): string { - const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` - return typeof suiteId === 'number' && Number.isFinite(suiteId) - ? `${base}#check-suite-${suiteId}` - : base -} - -function nullableString(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null -} - -function nullableNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] { - if (!Array.isArray(raw)) { - return [] - } - return raw - .filter((annotation): annotation is Record => Boolean(annotation)) - .map((annotation) => ({ - path: nullableString(annotation.path), - startLine: nullableNumber(annotation.start_line), - endLine: nullableNumber(annotation.end_line), - annotationLevel: nullableString(annotation.annotation_level), - title: nullableString(annotation.title), - message: nullableString(annotation.message) ?? '', - rawDetails: nullableString(annotation.raw_details) - })) -} - -function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] { - if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) { - return [] - } - const jobs = (raw as { jobs: unknown[] }).jobs - .filter((job): job is Record => Boolean(job)) - .map((job) => ({ - id: nullableNumber(job.id), - name: nullableString(job.name) ?? 'Unnamed job', - status: nullableString(job.status), - conclusion: nullableString(job.conclusion), - startedAt: nullableString(job.started_at), - completedAt: nullableString(job.completed_at), - url: nullableString(job.html_url), - logTail: null, - steps: Array.isArray(job.steps) - ? job.steps - .filter((step): step is Record => Boolean(step)) - .map((step) => ({ - name: nullableString(step.name) ?? 'Unnamed step', - status: nullableString(step.status), - conclusion: nullableString(step.conclusion), - startedAt: nullableString(step.started_at), - completedAt: nullableString(step.completed_at) - })) - : [] - })) - const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : [] - return exactMatches.length > 0 ? exactMatches : jobs -} - -function isCheckJobFailureState(state: string | null | undefined): boolean { - return ( - state === 'failure' || - state === 'failed' || - state === 'action_required' || - state === 'cancelled' || - state === 'stale' || - state === 'startup_failure' || - state === 'timed_out' - ) -} - -function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null { - if (job.id === null) { - return null - } - return `${job.id}:${job.completedAt ?? ''}` -} - -async function attachFailedJobLogTails( - jobs: PRCheckRunDetails['jobs'], - ownerRepo: GitHubApiRepository, - ghOptions: GhExecOptions -): Promise { - const failedJobs = jobs - .filter((job) => { - const state = job.conclusion ?? job.status - return job.id !== null && isCheckJobFailureState(state) - }) - .slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT) - - // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads. - for (const job of failedJobs) { - const jobCacheKey = getCheckJobLogTailCacheKey(job) - const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null - if (!cacheKey) { - continue - } - if (prCheckLogTailCache.has(cacheKey)) { - job.logTail = prCheckLogTailCache.get(cacheKey) ?? null - continue - } - try { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/jobs/${job.id}/logs`], - ghOptions - ) - job.logTail = sliceCheckLogTail(stdout) - } catch (err) { - rethrowCheckDetailsAbort(ghOptions.signal, err) - console.warn('getPRCheckDetails workflow job log fetch failed:', err) - job.logTail = null - } - setPrCheckLogTailCache(cacheKey, job.logTail) - } -} - -function getWorkflowRunIdFromCheckRun( - checkRun: Record | null -): number | undefined { - const checkSuite = checkRun?.check_suite - if (!checkSuite || typeof checkSuite !== 'object') { - return undefined - } - const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run - if (!workflowRun || typeof workflowRun !== 'object') { - return undefined - } - const id = (workflowRun as { id?: unknown }).id - return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined -} - -export async function getPRCheckDetails( - repoPath: string, - args: { - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubApiRepository | null - }, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {}, - callerSignal?: AbortSignal -): Promise { - const controller = new AbortController() - let hostDeadlineExpired = false - const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason) - if (callerSignal?.aborted) { - forwardCallerAbort() - } else { - callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true }) - } - const hostDeadline = setTimeout(() => { - hostDeadlineExpired = true - controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)) - }, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS) - let acquired = false - try { - const resolved = await waitForCheckDetailsResolution( - resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions), - controller.signal - ) - if (!resolved.ownerRepo) { - return null - } - const ownerRepo = resolved.ownerRepo - const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal } - await acquire(controller.signal) - acquired = true - let checkRun: Record | null = null - let annotations: PRCheckRunDetails['annotations'] = [] - if (args.checkRunId) { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`], - ghOptions - ) - checkRun = JSON.parse(stdout) as Record - try { - const annotationsResult = await ghExecFileAsync( - [ - 'api', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20` - ], - ghOptions - ) - annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout)) - } catch (err) { - rethrowCheckDetailsAbort(controller.signal, err) - console.warn('getPRCheckDetails annotations fetch failed:', err) - } - } - - const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun) - let jobs: PRCheckRunDetails['jobs'] = [] - if (workflowRunId) { - try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100` - ], - ghOptions - ) - jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName) - await attachFailedJobLogTails(jobs, ownerRepo, ghOptions) - } catch (err) { - rethrowCheckDetailsAbort(controller.signal, err) - console.warn('getPRCheckDetails workflow jobs fetch failed:', err) - } - } - - const output = - checkRun?.output && typeof checkRun.output === 'object' - ? (checkRun.output as Record) - : null - return { - name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check', - status: nullableString(checkRun?.status), - conclusion: nullableString(checkRun?.conclusion), - url: nullableString(checkRun?.html_url) ?? args.url ?? null, - detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null, - startedAt: nullableString(checkRun?.started_at), - completedAt: nullableString(checkRun?.completed_at), - title: nullableString(output?.title), - summary: nullableString(output?.summary), - text: nullableString(output?.text), - annotations, - jobs - } - } catch (err) { - console.warn('getPRCheckDetails failed:', err) - if (hostDeadlineExpired && !callerSignal?.aborted) { - throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE) - } - throw err - } finally { - clearTimeout(hostDeadline) - callerSignal?.removeEventListener('abort', forwardCallerAbort) - if (acquired) { - release() - } - } -} - -function parseActionsRunId(url: string | null | undefined): number | undefined { - if (!url) { - return undefined - } - const match = /\/actions\/runs\/(\d+)(?:\/|$)/.exec(url) - if (!match) { - return undefined - } - const id = Number(match[1]) - return Number.isSafeInteger(id) ? id : undefined -} - -export async function rerunPRChecks( - repoPath: string, - prNumber: number, - options: { - headSha?: string - failedOnly?: boolean - prRepo?: GitHubApiRepository | null - } = {}, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - options.prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - - const checks = await getPRChecks( - repoPath, - prNumber, - options.headSha, - ownerRepo, - { noCache: true }, - connectionId, - localGitOptions - ) - const candidates = options.failedOnly - ? checks.filter((check) => - ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') - ) - : checks - const workflowRunIds = new Set( - candidates - .map((check) => check.workflowRunId ?? parseActionsRunId(check.url)) - .filter((id): id is number => typeof id === 'number') - ) - const checkRunIds = new Set( - candidates - .filter((check) => !check.workflowRunId && !parseActionsRunId(check.url)) - .map((check) => check.checkRunId) - .filter((id): id is number => typeof id === 'number') - ) - - if (workflowRunIds.size === 0 && checkRunIds.size === 0) { - return { - ok: false, - error: options.failedOnly - ? 'No failed GitHub Actions checks to rerun.' - : 'No rerunnable checks found.' - } - } - - let count = 0 - await acquire() - try { - for (const runId of workflowRunIds) { - const endpoint = options.failedOnly - ? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs` - : `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun` - await ghExecFileAsync(['api', '-X', 'POST', endpoint], { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) - count += 1 - } - for (const checkRunId of checkRunIds) { - await ghExecFileAsync( - [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest` - ], - { ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } } - ) - count += 1 - } - return { ok: true, count } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: classifyGhError(message).message } - } finally { - release() - } -} - -// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them). -const REVIEW_THREADS_QUERY = ` -query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - nodes { - id - isResolved - line - startLine - originalLine - originalStartLine - comments(first: 100) { - nodes { - id - databaseId - author { __typename login avatarUrl(size: 48) } - body - createdAt - url - path - reactionGroups { - content - viewerHasReacted - reactors { - totalCount - } - } - } - } - } - } - comments(first: 100) { - nodes { - id - databaseId - author { __typename login avatarUrl(size: 48) } - body - createdAt - url - reactionGroups { - content - viewerHasReacted - reactors { - totalCount - } - } - } - } - reviews(first: 100) { - nodes { - id - databaseId - author { __typename login avatarUrl(size: 48) } - body - createdAt - url - reactionGroups { - content - viewerHasReacted - reactors { - totalCount - } - } - } - } - } - } -}` - -/** - * Get all comments on a PR — both top-level conversation comments and inline - * review comments (including suggestions). Uses GraphQL for review threads - * to get resolution status, REST for issue-level comments. - */ -export async function getPRComments( - repoPath: string, - prNumber: number, - options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null }, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - options?.prRepo, - connectionId, - localGitOptions - ) - if (connectionId && !ownerRepo) { - throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) - } - if (ownerRepo) { - await assertRateLimitBudget('core', ownerRepo, ghOptions) - } - await acquire() - try { - if (ownerRepo) { - // Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data. - const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] - const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}` - - // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero. - const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) - let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> - if (reviewThreadsGuard.blocked) { - reviewThreadsFetch = Promise.resolve(null) - } else { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - reviewThreadsFetch = ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${REVIEW_THREADS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `pr=${prNumber}` - ], - ghOptions - ) - } - const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([ - ghExecFileAsync( - ['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`], - ghOptions - ), - reviewThreadsFetch, - // Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads. - ghExecFileAsync( - ['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`], - ghOptions - ) - ]) - noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) - - // Parse issue comments (REST) - type RESTComment = { - id: number - user: { login: string; avatar_url: string; type?: string } | null - body: string - created_at: string - html_url: string - } - let issueComments: PRComment[] = [] - if (issueResult.status === 'fulfilled') { - issueComments = (JSON.parse(issueResult.value.stdout) as RESTComment[]).map( - (c): PRComment => ({ - id: c.id, - author: c.user?.login ?? 'ghost', - authorAvatarUrl: c.user?.avatar_url ?? '', - body: c.body ?? '', - createdAt: c.created_at, - url: c.html_url, - isBot: c.user?.type === 'Bot' - }) - ) - } else { - console.warn('Failed to fetch issue comments:', issueResult.reason) - } - - // Parse review threads (GraphQL) - type GQLThread = { - id: string - isResolved: boolean - line: number | null - startLine: number | null - originalLine: number | null - originalStartLine: number | null - comments: { - nodes: { - id: string - databaseId: number - author: { __typename?: string; login: string; avatarUrl: string } | null - body: string - createdAt: string - url: string - path: string - reactionGroups?: GitHubGraphQLReactionGroup[] | null - }[] - } - } - type GQLIssueComment = { - id: string - databaseId: number - author: { __typename?: string; login: string; avatarUrl: string } | null - body: string - createdAt: string - url: string - reactionGroups?: GitHubGraphQLReactionGroup[] | null - } - let graphQLReviewSummaries: PRComment[] | undefined - const reviewComments: PRComment[] = [] - if (threadsResult.status === 'fulfilled' && threadsResult.value) { - const threadsData = JSON.parse(threadsResult.value.stdout) as { - data: { - repository: { - pullRequest: { - reviewThreads: { nodes: GQLThread[] } - comments?: { nodes: GQLIssueComment[] } - reviews?: { nodes: GQLIssueComment[] } - } - } - } - } - const pullRequest = threadsData.data.repository.pullRequest - const graphQLIssueComments = (pullRequest.comments?.nodes ?? []).map( - (c): PRComment => ({ - id: c.databaseId, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url, - isBot: c.author?.__typename === 'Bot', - reactionSubjectId: c.id, - reactions: mapGraphQLReactionGroups(c.reactionGroups) - }) - ) - if (graphQLIssueComments.length > 0) { - issueComments = graphQLIssueComments - } - graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? []) - .filter((review) => review.body?.trim()) - .map( - (review): PRComment => ({ - id: review.databaseId, - author: review.author?.login ?? 'ghost', - authorAvatarUrl: review.author?.avatarUrl ?? '', - body: review.body, - createdAt: review.createdAt, - url: review.url, - isBot: review.author?.__typename === 'Bot', - reactionSubjectId: review.id, - reactions: mapGraphQLReactionGroups(review.reactionGroups) - }) - ) - - const threads = pullRequest.reviewThreads.nodes - for (const thread of threads) { - for (const c of thread.comments.nodes) { - reviewComments.push({ - id: c.databaseId, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url, - isBot: c.author?.__typename === 'Bot', - reactionSubjectId: c.id, - reactions: mapGraphQLReactionGroups(c.reactionGroups), - path: c.path, - threadId: thread.id, - isResolved: thread.isResolved, - isOutdated: thread.line == null, - // Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers. - line: thread.line ?? thread.originalLine ?? undefined, - startLine: thread.startLine ?? thread.originalStartLine ?? undefined - }) - } - } - } else { - if (threadsResult.status === 'rejected') { - console.warn('Failed to fetch review threads:', threadsResult.reason) - } - } - - // Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise. - type RESTReview = { - id: number - user: { login: string; avatar_url: string; type?: string } | null - body: string - state: string - submitted_at: string - html_url: string - } - let reviewSummaries: PRComment[] = [] - if (graphQLReviewSummaries) { - reviewSummaries = graphQLReviewSummaries - } else if (reviewsResult.status === 'fulfilled') { - reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[]) - .filter((r) => r.body?.trim()) - .map( - (r): PRComment => ({ - id: r.id, - author: r.user?.login ?? 'ghost', - authorAvatarUrl: r.user?.avatar_url ?? '', - body: r.body, - createdAt: r.submitted_at, - url: r.html_url, - isBot: r.user?.type === 'Bot' - }) - ) - } else { - console.warn('Failed to fetch review summaries:', reviewsResult.reason) - } - - const all = [...issueComments, ...reviewComments, ...reviewSummaries] - all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - return all - } - - // Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments) - const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'comments'], - ghOptions - ) - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const data = JSON.parse(stdout) as { - comments: { - author: { login: string } - body: string - createdAt: string - url: string - }[] - } - return (data.comments ?? []).map((c, i) => ({ - id: i, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url ?? '' - })) - } catch (err) { - console.warn('getPRComments failed:', err) - return [] - } finally { - release() - } -} - -export async function setPRCommentReaction( - repoPath: string, - reactionSubjectId: string, - content: GitHubReactionContent, - reacted: boolean, - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const mutation = reacted ? 'addReaction' : 'removeReaction' - const query = `mutation($subjectId: ID!, $content: ReactionContent!) { - ${mutation}(input: { subjectId: $subjectId, content: $content }) { - subject { id } - } - }` - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return false - } - const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) - if (guard.blocked) { - console.warn( - `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` - ) - return false - } - await acquire() - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${query}`, - '-f', - `subjectId=${reactionSubjectId}`, - '-f', - `content=${toGraphQLReactionContent(content)}` - ], - ghOptions - ) - return true - } catch (err) { - console.warn(`${mutation} failed:`, err) - return false - } finally { - release() - } -} - -/** - * Mark or unmark a PR file as viewed via GitHub's GraphQL API. - */ -export async function setPRFileViewed(args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - prRepo?: GitHubApiRepository | null - pullRequestId: string - path: string - viewed: boolean -}): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - args.repoPath, - args.prRepo, - args.connectionId, - args.localGitOptions - ) - if (!ownerRepo) { - return false - } - const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed' - const query = `mutation($pullRequestId: ID!, $path: String!) { - ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) { - pullRequest { id } - } - }` - await acquire() - try { - await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${query}`, - '-f', - `pullRequestId=${args.pullRequestId}`, - '-f', - `path=${args.path}` - ], - ghOptions - ) - return true - } catch (err) { - console.warn(`${mutation} failed:`, err) - return false - } finally { - release() - } -} - -/** - * Resolve or unresolve a PR review thread via GraphQL. - */ -export async function resolveReviewThread( - repoPath: string, - threadId: string, - resolve: boolean, - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread' - const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }` - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return false - } - const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) - if (guard.blocked) { - console.warn( - `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` - ) - return false - } - await acquire() - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - await ghExecFileAsync( - ['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`], - ghOptions - ) - return true - } catch (err) { - console.warn(`${mutation} failed:`, err) - return false - } finally { - release() - } -} - -function mapReviewCommentResponse( - data: { - id?: number - node_id?: string | null - user: { login: string; avatar_url: string; type?: string } | null - body?: string - created_at?: string - html_url?: string - path?: string - line?: number | null - }, - body: string, - path?: string, - line?: number, - startLine?: number, - threadId?: string -): PRComment { - return { - id: data.id ?? Date.now(), - reactionSubjectId: data.node_id?.trim() || undefined, - author: data.user?.login ?? 'You', - authorAvatarUrl: data.user?.avatar_url ?? '', - body: data.body ?? body, - createdAt: data.created_at ?? new Date().toISOString(), - url: data.html_url ?? '', - isBot: data.user?.type === 'Bot', - path: data.path ?? path, - line: data.line ?? line, - startLine, - threadId - } -} - -export async function addPRReviewCommentReply( - repoPath: string, - prNumber: number, - commentId: number, - body: string, - threadId?: string, - path?: string, - line?: number, - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - await acquire() - try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/comments/${commentId}/replies`, - '--raw-field', - `body=${body}` - ], - ghOptions - ) - const data = JSON.parse(stdout) as Parameters[0] - if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) { - return { ok: false, error: 'Unexpected response from GitHub' } - } - return { - ok: true, - comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId) - } - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGhError(stderr).message } - } finally { - release() - } -} - -export async function addPRReviewComment( - args: GitHubPRReviewCommentInput & { - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - } -): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - args.repoPath, - args.prRepo, - args.connectionId, - args.localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - await acquire() - try { - const fields = [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${args.prNumber}/comments`, - '--raw-field', - `body=${args.body}`, - '--raw-field', - `commit_id=${args.commitId}`, - '--raw-field', - `path=${args.path}`, - '--field', - `line=${String(args.line)}`, - '--raw-field', - 'side=RIGHT' - ] - if (typeof args.startLine === 'number' && args.startLine !== args.line) { - fields.push( - '--field', - `start_line=${String(args.startLine)}`, - '--raw-field', - 'start_side=RIGHT' - ) - } - const { stdout } = await ghExecFileAsync(fields, ghOptions) - return { - ok: true, - comment: mapReviewCommentResponse( - JSON.parse(stdout), - args.body, - args.path, - args.line, - args.startLine - ) - } - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGhError(stderr).message } - } finally { - release() - } -} - -/** - * Merge a PR by number using gh CLI. - * method: 'merge' | 'squash' | 'rebase' (default: 'squash') - */ -export async function mergePR( - repoPath: string, - prNumber: number, - method: 'merge' | 'squash' | 'rebase' = 'squash', - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - await acquire() - let concurrencySlotHeld = true - try { - let restData: PullRequestLookupData - try { - restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions, { - requireUsableStackMetadata: true - }) - } catch (err) { - const diagnostic = - err instanceof SyntaxError - ? 'invalid JSON response' - : err instanceof Error - ? err.message - : String(err) - console.warn( - `mergePR stack metadata probe failed for ${ownerRepo.owner}/${ownerRepo.repo}#${String(prNumber)}:`, - diagnostic - ) - return { ok: false, error: STACK_METADATA_UNAVAILABLE_ERROR } - } - if (restData.stack) { - const mergeMetadata = await detectRepositoryMergeMetadata( - ownerRepo, - restData.stack.baseRefName, - ghOptions, - githubPRStackExecutionScope(connectionId, localGitOptions) - ) - release() - concurrencySlotHeld = false - return await mergeGitHubPRStack({ - repository: ownerRepo, - prNumber, - method, - mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', - headSha: restData.headRefOid, - ghOptions - }) - } - const mergeBlocker = await getPRMergeBlocker( - repoPath, - prNumber, - ownerRepo, - ghOptions, - connectionId, - localGitOptions - ) - if (mergeBlocker) { - return { ok: false, error: mergeBlocker } - } - - // Don't use --delete-branch: it deletes the local branch, which fails while the worktree is checked out on it. - const args = ['pr', 'merge', String(prNumber), `--${method}`] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - await ghExecFileAsync(args, { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) - return { ok: true } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: message } - } finally { - if (concurrencySlotHeld) { - release() - } - } -} - -export async function setPRAutoMerge( - repoPath: string, - prNumber: number, - enabled: boolean, - method: GitHubPRMergeMethod = 'squash', - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - await acquire() - try { - if (enabled) { - return await enablePRAutoMerge(prNumber, method, ownerRepo, ghOptions) - } - const args = ['pr', 'merge', String(prNumber), '--disable-auto'] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - await ghExecFileAsync(args, { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) - return { ok: true } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: classifySetAutoMergeError(message) } - } finally { - release() - } -} - -// Why: GitHub rejects auto-merge on an already-mergeable PR ("clean status"); surface an actionable message instead of the raw error. -function classifySetAutoMergeError(message: string): string { - if (/in clean status/i.test(message)) { - return 'This pull request can already be merged. Use Merge instead of auto-merge.' - } - return classifyGhError(message).message -} - -type PRAutoMergeIdentity = { - id?: string - headRefOid?: string - baseRefName?: string -} - -async function getPRAutoMergeIdentity( - prNumber: number, - ownerRepo: GitHubApiRepository | null, - ghOptions: GhExecOptions -): Promise { - const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - const { stdout } = await ghExecFileAsync(args, ghOptions) - const data = JSON.parse(stdout) as PRAutoMergeIdentity - return { - id: typeof data.id === 'string' ? data.id : undefined, - headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined, - baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined - } -} - -async function runPRAutoMergeCommand( - prNumber: number, - method: GitHubPRMergeMethod, - ownerRepo: GitHubApiRepository | null, - ghOptions: GhExecOptions -): Promise { - const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - await ghExecFileAsync(args, { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) -} - -async function shouldUseMergeQueueAutoMerge( - pr: PRAutoMergeIdentity, - ownerRepo: GitHubApiRepository | null, - ghOptions: GhExecOptions -): Promise { - if (!ownerRepo || !pr.baseRefName) { - return false - } - const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions) - return mergeMetadata.mergeQueueRequired === true -} - -async function enablePRAutoMerge( - prNumber: number, - method: GitHubPRMergeMethod, - ownerRepo: GitHubApiRepository | null, - ghOptions: GhExecOptions -): Promise<{ ok: true } | { ok: false; error: string }> { - if (ownerRepo) { - try { - const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) - if (restData.stack) { - return { - ok: false, - error: 'GitHub does not support auto-merge for stacked pull requests.' - } - } - } catch { - // GitHub remains authoritative when stack metadata cannot be read. - } - } - const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) - if (!pr?.id) { - return { ok: false, error: 'Could not resolve GitHub pull request ID' } - } - const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions) - if (useMergeQueue) { - await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) - return { ok: true } - } - const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) { - enablePullRequestAutoMerge(input: { - pullRequestId: $pullRequestId, - mergeMethod: $mergeMethod, - expectedHeadOid: $expectedHeadOid - }) { - pullRequest { id } - } - }` - const args = [ - 'api', - 'graphql', - '-f', - `query=${query}`, - '-f', - `pullRequestId=${pr.id}`, - '-f', - `mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}` - ] - if (pr.headRefOid) { - args.push('-f', `expectedHeadOid=${pr.headRefOid}`) - } - // Why: `gh pr merge --auto` can merge immediately; this mutation only creates the auto-merge request, letting branch requirements gate it. - await ghExecFileAsync(args, { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) - return { ok: true } -} - -async function getPRMergeBlocker( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - ghOptions: GhExecOptions, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - - try { - const pr = await getPRByNumber( - ownerRepo, - prNumber, - ghOptions, - githubPRStackExecutionScope(connectionId, localGitOptions) - ) - if (!pr) { - return null - } - if (pr.reviewDecision === 'REVIEW_REQUIRED') { - return 'This pull request requires review approval before it can be merged.' - } - if (pr.reviewDecision === 'CHANGES_REQUESTED') { - return 'This pull request has requested changes and cannot be merged yet.' - } - if (pr.mergeQueueRequired === true) { - return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' - } - // Why: conflict summaries shell out to local git; skip for SSH repos until that helper routes through the SSH provider. - if ( - connectionId || - pr.mergeable !== 'CONFLICTING' || - !pr.baseRefName || - !pr.baseRefOid || - !pr.headRefOid - ) { - return null - } - - const summary = await getPRConflictSummary( - repoPath, - pr.baseRefName, - pr.baseRefOid, - pr.headRefOid, - localGitOptions - ) - return formatMergeConflictBlocker(pr.baseRefName, summary) - } catch { - // Why: conflict preflight should improve stale UI diagnostics, not block merge on a transient lookup failure. - return null - } -} - -function formatMergeConflictBlocker( - baseRefName: string, - summary: PRConflictSummary | undefined -): string { - const heading = 'This pull request has merge conflicts and cannot be merged yet.' - if (!summary || summary.files.length === 0) { - return `${heading}\nUpdate the branch with ${baseRefName} and resolve the conflicts before merging.` - } - - const files = summary.files.map((file) => `- ${file}`).join('\n') - const behind = `${summary.commitsBehind} commit${summary.commitsBehind === 1 ? '' : 's'} behind ${baseRefName}` - return `${heading}\n${behind} (base commit: ${summary.baseCommit}).\n\nConflicting files:\n${files}` -} - -export async function updatePRState( - repoPath: string, - prNumber: number, - updates: GitHubPullRequestStateUpdate, - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - - await acquire() - try { - const cmd = updates.state === 'closed' ? 'close' : 'reopen' - // Why: gh's PR commands use GitHub's supported reopen flow; REST state PATCH can 422 on reopen. - await ghExecFileAsync( - ['pr', cmd, String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`], - { - ...ghOptions - } - ) - return { ok: true } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: classifyGhError(message).message } - } finally { - release() - } -} - -export async function requestPRReviewers( - repoPath: string, - prNumber: number, - reviewers: string[], - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) - if (logins.length === 0) { - return { ok: false, error: 'Enter at least one reviewer' } - } - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - await acquire() - try { - const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - await ghExecFileAsync(args, { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) - return { ok: true } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: message } - } finally { - release() - } -} - -export async function removePRReviewers( - repoPath: string, - prNumber: number, - reviewers: string[], - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) - if (logins.length === 0) { - return { ok: false, error: 'Enter at least one reviewer' } - } - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - await acquire() - try { - const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - await ghExecFileAsync(args, { - ...ghOptions, - env: { ...process.env, GH_PROMPT_DISABLED: '1' } - }) - return { ok: true } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: message } - } finally { - release() - } -} - -/** - * Update a PR's title. - */ -export async function updatePRTitle( - repoPath: string, - prNumber: number, - title: string, - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return false - } - await acquire() - try { - const args = ['pr', 'edit', String(prNumber), '--title', title] - if (ownerRepo) { - args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - await ghExecFileAsync(args, { - ...ghOptions - }) - return true - } catch (err) { - console.warn('updatePRTitle failed:', err) - return false - } finally { - release() - } -} - -export async function updatePRDetails( - repoPath: string, - prNumber: number, - updates: { title?: string; body?: string }, - connectionId?: string | null, - prRepo?: GitHubApiRepository | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } - } - - const fields: string[] = [] - if (updates.title !== undefined) { - const title = updates.title.trim() - if (!title) { - return { ok: false, error: 'Title is required' } - } - fields.push(`title=${title}`) - } - if (updates.body !== undefined) { - fields.push(`body=${updates.body}`) - } - if (fields.length === 0) { - return { ok: true } - } - - await acquire() - try { - await ghExecFileAsync( - [ - 'api', - '-X', - 'PATCH', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`, - ...fields.flatMap((field) => ['--raw-field', field]) - ], - ghOptions - ) - return { ok: true } - } catch (err) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' - return { ok: false, error: classifyGhError(message).message } - } finally { - release() - } -} diff --git a/src/main/github/client/check/check-detail-field-mapping.ts b/src/main/github/client/check/check-detail-field-mapping.ts new file mode 100644 index 00000000000..a5ac7b7d5d5 --- /dev/null +++ b/src/main/github/client/check/check-detail-field-mapping.ts @@ -0,0 +1,83 @@ +import type { PRCheckRunDetails } from '../../../../shared/github/check-types' +export function nullableString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function nullableNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] { + if (!Array.isArray(raw)) { + return [] + } + return raw + .filter((annotation): annotation is Record => Boolean(annotation)) + .map((annotation) => ({ + path: nullableString(annotation.path), + startLine: nullableNumber(annotation.start_line), + endLine: nullableNumber(annotation.end_line), + annotationLevel: nullableString(annotation.annotation_level), + title: nullableString(annotation.title), + message: nullableString(annotation.message) ?? '', + rawDetails: nullableString(annotation.raw_details) + })) +} + +export function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] { + if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) { + return [] + } + const jobs = (raw as { jobs: unknown[] }).jobs + .filter((job): job is Record => Boolean(job)) + .map((job) => ({ + id: nullableNumber(job.id), + name: nullableString(job.name) ?? 'Unnamed job', + status: nullableString(job.status), + conclusion: nullableString(job.conclusion), + startedAt: nullableString(job.started_at), + completedAt: nullableString(job.completed_at), + url: nullableString(job.html_url), + logTail: null, + steps: Array.isArray(job.steps) + ? job.steps + .filter((step): step is Record => Boolean(step)) + .map((step) => ({ + name: nullableString(step.name) ?? 'Unnamed step', + status: nullableString(step.status), + conclusion: nullableString(step.conclusion), + startedAt: nullableString(step.started_at), + completedAt: nullableString(step.completed_at) + })) + : [] + })) + const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : [] + return exactMatches.length > 0 ? exactMatches : jobs +} + +export function getWorkflowRunIdFromCheckRun( + checkRun: Record | null +): number | undefined { + const checkSuite = checkRun?.check_suite + if (!checkSuite || typeof checkSuite !== 'object') { + return undefined + } + const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run + if (!workflowRun || typeof workflowRun !== 'object') { + return undefined + } + const id = (workflowRun as { id?: unknown }).id + return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined +} + +export 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 +} diff --git a/src/main/github/client/check/check-details-abort.ts b/src/main/github/client/check/check-details-abort.ts new file mode 100644 index 00000000000..cda9370885b --- /dev/null +++ b/src/main/github/client/check/check-details-abort.ts @@ -0,0 +1,26 @@ +export function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void { + if (signal?.aborted) { + throw error + } +} + +export function waitForCheckDetailsResolution( + operation: Promise, + signal: AbortSignal +): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason) + } + return new Promise((resolve, reject) => { + const finish = (settle: () => void): void => { + signal.removeEventListener('abort', onAbort) + settle() + } + const onAbort = (): void => finish(() => reject(signal.reason)) + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)) + ) + }) +} diff --git a/src/main/github/client/check/check-job-log-tails.ts b/src/main/github/client/check/check-job-log-tails.ts new file mode 100644 index 00000000000..d6ee40f62d3 --- /dev/null +++ b/src/main/github/client/check/check-job-log-tails.ts @@ -0,0 +1,85 @@ +import type { PRCheckRunDetails } from '../../../../shared/github/check-types' +import { sliceCheckLogTail } from '../../../../shared/check-job-log-tail-slice' +import { ghExecFileAsync } from '../../gh-utils' +import type { GitHubApiRepository } from '../../github-api-repository' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import type { GhExecOptions } from './../github-exec-scope' +import { rethrowCheckDetailsAbort } from './check-details-abort' +export const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5 + +// Why: only the tail is kept, but the whole log buffers first — the default 10MiB cap drops long CI logs. +// Still far below the V8 string ceiling that DEFAULT_GIT_MAX_BUFFER guards against. +export const PR_CHECK_LOG_MAX_BUFFER = 64 * 1024 * 1024 + +// Why: each entry holds up to 16KB of log text; bound the cache so a long session can't grow it unbounded. +export const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128 + +export const prCheckLogTailCache = new Map() + +export 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) + } +} + +export 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' + ) +} + +export function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null { + if (job.id === null) { + return null + } + return `${job.id}:${job.completedAt ?? ''}` +} + +export async function attachFailedJobLogTails( + jobs: PRCheckRunDetails['jobs'], + ownerRepo: GitHubApiRepository, + ghOptions: GhExecOptions +): Promise { + const failedJobs = jobs + .filter((job) => { + const state = job.conclusion ?? job.status + return job.id !== null && isCheckJobFailureState(state) + }) + .slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT) + + // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads. + for (const job of failedJobs) { + const jobCacheKey = getCheckJobLogTailCacheKey(job) + const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null + if (!cacheKey) { + continue + } + if (prCheckLogTailCache.has(cacheKey)) { + job.logTail = prCheckLogTailCache.get(cacheKey) ?? null + continue + } + try { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/jobs/${job.id}/logs`], + { ...ghOptions, maxBuffer: PR_CHECK_LOG_MAX_BUFFER } + ) + job.logTail = sliceCheckLogTail(stdout) + } catch (err) { + rethrowCheckDetailsAbort(ghOptions.signal, err) + console.warn('getPRCheckDetails workflow job log fetch failed:', err) + job.logTail = null + } + setPrCheckLogTailCache(cacheKey, job.logTail) + } +} diff --git a/src/main/github/client/check/get-pr-check-details.ts b/src/main/github/client/check/get-pr-check-details.ts new file mode 100644 index 00000000000..8a5c2c2487d --- /dev/null +++ b/src/main/github/client/check/get-pr-check-details.ts @@ -0,0 +1,128 @@ +import type { PRCheckRunDetails } from '../../../../shared/github/check-types' +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { + GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, + GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE +} from '../../../../shared/github/check-details-deadline' +import type { GhExecOptions } from './../github-exec-scope' +import { + nullableString, + mapCheckAnnotations, + mapWorkflowJobs, + getWorkflowRunIdFromCheckRun +} from './check-detail-field-mapping' +import { rethrowCheckDetailsAbort, waitForCheckDetailsResolution } from './check-details-abort' +import { attachFailedJobLogTails } from './check-job-log-tails' +export async function getPRCheckDetails( + repoPath: string, + args: { + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubApiRepository | null + }, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {}, + callerSignal?: AbortSignal +): Promise { + const controller = new AbortController() + let hostDeadlineExpired = false + const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason) + if (callerSignal?.aborted) { + forwardCallerAbort() + } else { + callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true }) + } + const hostDeadline = setTimeout(() => { + hostDeadlineExpired = true + controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)) + }, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS) + let acquired = false + try { + const resolved = await waitForCheckDetailsResolution( + resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions), + controller.signal + ) + if (!resolved.ownerRepo) { + return null + } + const ownerRepo = resolved.ownerRepo + const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal } + await acquire(controller.signal) + acquired = true + let checkRun: Record | null = null + let annotations: PRCheckRunDetails['annotations'] = [] + if (args.checkRunId) { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`], + ghOptions + ) + checkRun = JSON.parse(stdout) as Record + try { + const annotationsResult = await ghExecFileAsync( + [ + 'api', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20` + ], + ghOptions + ) + annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout)) + } catch (err) { + rethrowCheckDetailsAbort(controller.signal, err) + console.warn('getPRCheckDetails annotations fetch failed:', err) + } + } + + const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun) + let jobs: PRCheckRunDetails['jobs'] = [] + if (workflowRunId) { + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100` + ], + ghOptions + ) + jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName) + await attachFailedJobLogTails(jobs, ownerRepo, ghOptions) + } catch (err) { + rethrowCheckDetailsAbort(controller.signal, err) + console.warn('getPRCheckDetails workflow jobs fetch failed:', err) + } + } + + const output = + checkRun?.output && typeof checkRun.output === 'object' + ? (checkRun.output as Record) + : null + return { + name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check', + status: nullableString(checkRun?.status), + conclusion: nullableString(checkRun?.conclusion), + url: nullableString(checkRun?.html_url) ?? args.url ?? null, + detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null, + startedAt: nullableString(checkRun?.started_at), + completedAt: nullableString(checkRun?.completed_at), + title: nullableString(output?.title), + summary: nullableString(output?.summary), + text: nullableString(output?.text), + annotations, + jobs + } + } catch (err) { + console.warn('getPRCheckDetails failed:', err) + if (hostDeadlineExpired && !callerSignal?.aborted) { + throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE) + } + throw err + } finally { + clearTimeout(hostDeadline) + callerSignal?.removeEventListener('abort', forwardCallerAbort) + if (acquired) { + release() + } + } +} diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts new file mode 100644 index 00000000000..50db1ee9926 --- /dev/null +++ b/src/main/github/client/check/get-pr-checks.ts @@ -0,0 +1,233 @@ +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { extractExecError } from '../../../git/exec-error' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { mapCheckStatus, mapCheckConclusion } from '../../mappers' +import { noteRepositoryRateLimitSpend } from '../../rate-limit' +import type { GhExecOptions } from './../github-exec-scope' +import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit' +import { + PR_CHECKS_ROLLUP_QUERY, + type GraphQLPRChecksResponse, + type RestCheckRun, + type RestCommitStatus, + type RestCheckSuite +} from './pr-checks-graphql-query' +import { + mapRestCheckRun, + mapRestCommitStatus, + mapGraphQLPRChecksResponse, + getPendingApprovalCheckSuiteName, + getPendingApprovalCheckSuiteUrl +} from './pr-checks-response-mapping' +import { parseActionsRunId } from './check-detail-field-mapping' +export async function getPRChecksViaRestFallback( + ownerRepo: GitHubApiRepository, + headSha: string | undefined, + ghOptions: GhExecOptions, + noCache?: boolean +): Promise { + if (!headSha) { + return null + } + try { + await assertRateLimitBudget('core', ownerRepo, ghOptions) + } catch (err) { + console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err) + return null + } + + await acquire() + try { + const cacheArgs = noCache ? [] : ['--cache', '60s'] + const encodedHeadSha = encodeURIComponent(headSha) + const { stdout } = await ghExecFileAsync( + [ + 'api', + ...cacheArgs, + `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-runs?per_page=100` + ], + ghOptions + ) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) + const checkRunData = JSON.parse(stdout) as { + check_runs?: RestCheckRun[] + } + const checkRuns = (checkRunData.check_runs ?? []).map(mapRestCheckRun) + const checkRunNames = new Set(checkRuns.map((check) => check.name)) + + let legacyStatuses: PRCheckDetail[] = [] + try { + const statusResult = await ghExecFileAsync( + [ + 'api', + ...cacheArgs, + `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100` + ], + ghOptions + ) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) + const statusData = JSON.parse(statusResult.stdout) as { + statuses?: RestCommitStatus[] + } + legacyStatuses = (statusData.statuses ?? []) + .map(mapRestCommitStatus) + .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)) + } catch (err) { + // Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails. + console.warn('getPRChecks REST status fallback failed:', err) + } + + let pendingApprovalChecks: PRCheckDetail[] = [] + try { + const suitesResult = await ghExecFileAsync( + [ + 'api', + ...cacheArgs, + `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-suites?per_page=100` + ], + ghOptions + ) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) + const suitesData = JSON.parse(suitesResult.stdout) as { + check_suites?: RestCheckSuite[] + } + pendingApprovalChecks = (suitesData.check_suites ?? []) + .filter((suite) => suite.conclusion?.toLowerCase() === 'action_required') + .map((suite, index) => ({ + name: getPendingApprovalCheckSuiteName(suite, headSha, index), + status: 'completed' as const, + conclusion: 'action_required' as const, + url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id) + })) + } catch (err) { + console.warn('getPRChecks REST check-suite fallback failed:', err) + } + + const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks] + return checks.length > 0 ? checks : null + } catch (err) { + console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err) + return null + } finally { + release() + } +} + +/** + * Get detailed check statuses for a PR. + * Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses + * arrive in one cached request; suite-only approval blockers are included too. + */ +export async function getPRChecks( + repoPath: string, + prNumber: number, + headSha?: string, + prRepo?: GitHubApiRepository | null, + options?: { noCache?: boolean }, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + void headSha + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (connectionId && !ownerRepo) { + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) + } + const fallbackToPRChecks = async (): Promise => { + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) + await acquire() + try { + const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] + if (ownerRepo) { + fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => { + const { stderr } = extractExecError(err) + // Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure. + if (stderr.toLowerCase().includes('no checks reported')) { + return { stdout: '[]', stderr } + } + throw err + }) + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] + return data.map((d) => ({ + name: d.name, + status: mapCheckStatus(d.state), + conclusion: mapCheckConclusion(d.state), + url: d.link || null, + workflowRunId: parseActionsRunId(d.link) + })) + } finally { + release() + } + } + + if (ownerRepo) { + let canUseGraphQLRollup = true + try { + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) + } catch (err) { + canUseGraphQLRollup = false + console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err) + } + if (canUseGraphQLRollup) { + await acquire() + try { + // Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data. + const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + ...cacheArgs, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-F', + `pr=${prNumber}`, + '-f', + `query=${PR_CHECKS_ROLLUP_QUERY}` + ], + ghOptions + ) + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + const checks = mapGraphQLPRChecksResponse( + ownerRepo, + JSON.parse(stdout) as GraphQLPRChecksResponse + ) + if (checks !== null) { + return checks + } + } catch (err) { + // Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable. + console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err) + } finally { + release() + } + } + const restChecks = await getPRChecksViaRestFallback( + ownerRepo, + headSha, + ghOptions, + options?.noCache + ) + if (restChecks !== null) { + return restChecks + } + } + + try { + return await fallbackToPRChecks() + } catch (err) { + console.warn('getPRChecks failed:', err) + throw err + } +} diff --git a/src/main/github/client/check/pr-checks-graphql-query.ts b/src/main/github/client/check/pr-checks-graphql-query.ts new file mode 100644 index 00000000000..edd1cac1b74 --- /dev/null +++ b/src/main/github/client/check/pr-checks-graphql-query.ts @@ -0,0 +1,133 @@ +export 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 + } + } + } + } + } + } + } + } +} +` + +export type GraphQLPRChecksResponse = { + data?: { + repository?: { + pullRequest?: { + headRefOid?: string | null + commits?: { + nodes?: { commit?: GraphQLPRChecksCommit | null }[] | null + } | null + } | null + } | null + } | null +} + +export type GraphQLPRChecksCommit = { + statusCheckRollup?: { + contexts?: { + nodes?: GraphQLStatusCheckContext[] | null + } | null + } | null + checkSuites?: { + nodes?: GraphQLCheckSuite[] | null + } | null +} + +export 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 +} + +export type GraphQLStatusContext = { + __typename: 'StatusContext' + context?: string | null + state?: string | null + targetUrl?: string | null +} + +export type GraphQLStatusCheckContext = + | GraphQLCheckRunContext + | GraphQLStatusContext + | { __typename?: string | null } + +export type GraphQLCheckSuite = { + databaseId?: number | null + status?: string | null + conclusion?: string | null + url?: string | null + app?: { name?: string | null; slug?: string | null } | null +} + +export type RestCheckRun = { + id?: number + name: string + status: string + conclusion: string | null + html_url: string + details_url: string | null +} + +export type RestCommitStatus = { + context?: string + state?: string + target_url?: string | null +} + +export type RestCheckSuite = { + id?: number | null + status: string | null + conclusion: string | null + app?: { name?: string | null; slug?: string | null } | null +} diff --git a/src/main/github/client/check/pr-checks-response-mapping.ts b/src/main/github/client/check/pr-checks-response-mapping.ts new file mode 100644 index 00000000000..362bbf6e137 --- /dev/null +++ b/src/main/github/client/check/pr-checks-response-mapping.ts @@ -0,0 +1,186 @@ +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import { githubRepositoryWebHost, type GitHubApiRepository } from '../../github-api-repository' +import { + mapCheckRunRESTStatus, + mapCheckRunRESTConclusion, + mapCommitStatusRESTStatus, + mapCommitStatusRESTConclusion +} from '../../mappers' +import type { + GraphQLPRChecksResponse, + GraphQLCheckRunContext, + GraphQLStatusContext, + GraphQLStatusCheckContext, + GraphQLCheckSuite, + RestCheckRun, + RestCommitStatus +} from './pr-checks-graphql-query' +import { nullableString, nullableNumber, parseActionsRunId } from './check-detail-field-mapping' +export function isGraphQLCheckRunContext( + context: GraphQLStatusCheckContext +): context is GraphQLCheckRunContext { + return context.__typename === 'CheckRun' +} + +export function isGraphQLStatusContext( + context: GraphQLStatusCheckContext +): context is GraphQLStatusContext { + return context.__typename === 'StatusContext' +} + +export 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 } : {}) + } +} + +export 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 } : {}) + } +} + +export 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) + } +} + +export 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 } : {}) + } +} + +export function mapGraphQLPendingApprovalCheckSuite( + ownerRepo: GitHubApiRepository, + suite: GraphQLCheckSuite, + headSha: string | null | undefined, + index: number +): PRCheckDetail { + return { + name: getPendingApprovalCheckSuiteName(suite, headSha, index), + status: 'completed', + conclusion: 'action_required', + // Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one. + url: + nullableString(suite.url) ?? + (headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null) + } +} + +export function mapGraphQLPRChecksResponse( + ownerRepo: GitHubApiRepository, + response: GraphQLPRChecksResponse +): PRCheckDetail[] | null { + const pullRequest = response.data?.repository?.pullRequest + if (!pullRequest) { + return null + } + const commit = pullRequest.commits?.nodes?.[0]?.commit + if (!commit) { + return [] + } + + const contexts = commit.statusCheckRollup?.contexts?.nodes ?? [] + const checkRunContexts = contexts.filter(isGraphQLCheckRunContext) + const checkRuns = checkRunContexts + .map(mapGraphQLCheckRunContext) + .filter((check): check is PRCheckDetail => check !== null) + const checkRunNames = new Set(checkRuns.map((check) => check.name)) + const checkSuiteIdsWithRuns = new Set( + checkRunContexts + .map((context) => nullableNumber(context.checkSuite?.databaseId)) + .filter((id): id is number => id !== null) + ) + // Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions. + const legacyStatuses = contexts + .filter(isGraphQLStatusContext) + .map(mapGraphQLStatusContext) + .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)) + const pendingApprovalChecks = (commit.checkSuites?.nodes ?? []) + .filter((suite) => suite.conclusion?.toLowerCase() === 'action_required') + .filter((suite) => { + const suiteId = nullableNumber(suite.databaseId) + return suiteId === null || !checkSuiteIdsWithRuns.has(suiteId) + }) + .map((suite, index) => + mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index) + ) + + return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks] +} + +export 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}` +} + +export function getPendingApprovalCheckSuiteUrl( + ownerRepo: GitHubApiRepository, + headSha: string, + suiteId: number | null | undefined +): string { + const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` + return typeof suiteId === 'number' && Number.isFinite(suiteId) + ? `${base}#check-suite-${suiteId}` + : base +} diff --git a/src/main/github/client/check/rerun-pr-checks.ts b/src/main/github/client/check/rerun-pr-checks.ts new file mode 100644 index 00000000000..82059bab664 --- /dev/null +++ b/src/main/github/client/check/rerun-pr-checks.ts @@ -0,0 +1,105 @@ +import type { GitHubRerunPRChecksResult } from '../../../../shared/github/check-types' +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +// Why: pure error helpers come from their own modules so tests that mock gh-utils still classify for real. +import { extractExecError } from '../../../git/exec-error' +import { classifyRerunChecksError } from '../../gh-error-classification' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { getPRChecks } from './get-pr-checks' +import { parseActionsRunId } from './check-detail-field-mapping' +export async function rerunPRChecks( + repoPath: string, + prNumber: number, + options: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubApiRepository | null + } = {}, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + options.prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + + const checks = await getPRChecks( + repoPath, + prNumber, + options.headSha, + ownerRepo, + { noCache: true }, + connectionId, + localGitOptions + ) + const candidates = options.failedOnly + ? checks.filter((check) => + ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') + ) + : checks + const workflowRunIds = new Set( + candidates + .map((check) => check.workflowRunId ?? parseActionsRunId(check.url)) + .filter((id): id is number => typeof id === 'number') + ) + const checkRunIds = new Set( + candidates + .filter((check) => !check.workflowRunId && !parseActionsRunId(check.url)) + .map((check) => check.checkRunId) + .filter((id): id is number => typeof id === 'number') + ) + + if (workflowRunIds.size === 0 && checkRunIds.size === 0) { + return { + ok: false, + error: options.failedOnly + ? 'No failed GitHub Actions checks to rerun.' + : 'No rerunnable checks found.' + } + } + + let count = 0 + await acquire() + try { + for (const runId of workflowRunIds) { + const endpoint = options.failedOnly + ? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs` + : `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun` + await ghExecFileAsync(['api', '-X', 'POST', endpoint], { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + count += 1 + } + for (const checkRunId of checkRunIds) { + await ghExecFileAsync( + [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest` + ], + { ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } } + ) + count += 1 + } + return { ok: true, count } + } catch (err) { + const { stderr } = extractExecError(err) + const classified = classifyRerunChecksError(stderr).message + // Why: these POSTs are not idempotent — say how many reruns already started so a retry isn't blind. + return { + ok: false, + error: + count > 0 + ? `${classified} (${count} rerun${count === 1 ? '' : 's'} already started)` + : classified + } + } finally { + release() + } +} diff --git a/src/main/github/client/create/add-pr-review-comment.ts b/src/main/github/client/create/add-pr-review-comment.ts new file mode 100644 index 00000000000..b3c51440a6a --- /dev/null +++ b/src/main/github/client/create/add-pr-review-comment.ts @@ -0,0 +1,121 @@ +import type { + GitHubCommentResult, + GitHubPRReviewCommentInput +} from '../../../../shared/github/comment-types' +import { + ghExecFileAsync, + acquire, + release, + classifyGhError, + type LocalGitExecOptions +} from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { mapReviewCommentResponse } from './../map/review-comment-response' +export async function addPRReviewComment( + args: GitHubPRReviewCommentInput & { + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + } +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + await acquire() + try { + const fields = [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${args.prNumber}/comments`, + '--raw-field', + `body=${args.body}`, + '--raw-field', + `commit_id=${args.commitId}`, + '--raw-field', + `path=${args.path}`, + '--field', + `line=${String(args.line)}`, + '--raw-field', + 'side=RIGHT' + ] + if (typeof args.startLine === 'number' && args.startLine !== args.line) { + fields.push( + '--field', + `start_line=${String(args.startLine)}`, + '--raw-field', + 'start_side=RIGHT' + ) + } + const { stdout } = await ghExecFileAsync(fields, ghOptions) + const data = JSON.parse(stdout) as Parameters[0] + // Why: mapReviewCommentResponse substitutes Date.now() for a missing id, which later replies/reactions would target. + 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, 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() + } +} + +export async function addPRReviewCommentReply( + repoPath: string, + prNumber: number, + commentId: number, + body: string, + threadId?: string, + path?: string, + line?: number, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + await acquire() + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/comments/${commentId}/replies`, + '--raw-field', + `body=${body}` + ], + ghOptions + ) + const data = JSON.parse(stdout) as Parameters[0] + if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) { + return { ok: false, error: 'Unexpected response from GitHub' } + } + return { + ok: true, + comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId) + } + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGhError(stderr).message } + } finally { + release() + } +} diff --git a/src/main/github/client/create/create-github-pull-request.ts b/src/main/github/client/create/create-github-pull-request.ts new file mode 100644 index 00000000000..68ea9fbe6e0 --- /dev/null +++ b/src/main/github/client/create/create-github-pull-request.ts @@ -0,0 +1,162 @@ +import type { + CreateHostedReviewInput, + CreateHostedReviewResult +} from '../../../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../../../shared/hosted-review-refs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + ghExecFileAsync, + acquire, + release, + ghRepoExecOptions, + githubRepoContext +} from '../../gh-utils' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../../../source-control/hosted-review-git-options' +import { getOriginGitHubApiRepository, githubHostExecOptions } from '../../github-api-repository' +import { classifyCreatePRError, parseCreatePRPayload } from './create-pr-error-classification' +import { findOpenPRByHeadBase, readPullRequestTemplate } from './pull-request-template' +export async function createGitHubPullRequest( + repoPath: string, + input: CreateHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + if (input.provider !== 'github') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating reviews for this provider is not supported yet.' + } + } + + // Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312). + const ownerRepo = await getOriginGitHubApiRepository( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) + if (!ownerRepo) { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating pull requests requires a GitHub remote.' + } + } + // The runner host-qualifies --repo from options.host for GHES (#8312). + const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}` + + const base = normalizeHostedReviewBaseRef(input.base) + const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined + const title = input.title.trim() + if (!base || !title) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: base branch and title are required.' + } + } + if (head && head.toLowerCase() === base.toLowerCase()) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: choose a different base branch before creating a pull request.' + } + } + + const tempDir = await mkdtemp(join(tmpdir(), 'orca-pr-body-')) + await acquire() + const bodyPath = join(tempDir, 'body.md') + try { + const body = + input.useTemplate && !input.body?.trim() + ? await readPullRequestTemplate(repoPath, connectionId) + : (input.body ?? '') + await writeFile(bodyPath, body, 'utf8') + const createArgs = [ + 'pr', + 'create', + '--repo', + repoArg, + '--base', + base, + '--title', + title, + '--body-file', + bodyPath + ] + if (head) { + createArgs.push('--head', head) + } + if (input.draft) { + createArgs.push('--draft') + } + try { + const context = githubRepoContext(repoPath, connectionId) + const { stdout } = await ghExecFileAsync(createArgs, { + ...ghRepoExecOptions(context), + ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), + ...githubHostExecOptions(ownerRepo), + timeout: 60_000, + idempotent: false + }) + const created = parseCreatePRPayload(stdout) + if (created) { + return { ok: true, ...created } + } + const found = head + ? await findOpenPRByHeadBase({ + repoPath, + repo: ownerRepo, + head, + base, + connectionId, + options + }).catch(() => null) + : null + if (found) { + return { ok: true, ...found } + } + return { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } catch (error) { + const classified = classifyCreatePRError(error) + if ( + !classified.ok && + (classified.code === 'already_exists' || classified.code === 'unknown_completion') && + head + ) { + const existing = await findOpenPRByHeadBase({ + repoPath, + repo: ownerRepo, + head, + base, + connectionId, + options + }).catch(() => null) + if (existing) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.', + existingReview: existing + } + } + } + return classified + } + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + release() + } +} diff --git a/src/main/github/client/create/create-pr-error-classification.ts b/src/main/github/client/create/create-pr-error-classification.ts new file mode 100644 index 00000000000..a8dfd571368 --- /dev/null +++ b/src/main/github/client/create/create-pr-error-classification.ts @@ -0,0 +1,74 @@ +import type { CreateHostedReviewResult } from '../../../../shared/hosted-review' +import { extractExecError } from '../../../git/exec-error' +export 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.' + } +} + +export function parseCreatePRPayload(stdout: string): { number: number; url: string } | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { number?: unknown; url?: unknown } + const number = Number(parsed.number) + const url = typeof parsed.url === 'string' ? parsed.url.trim() : '' + if (Number.isInteger(number) && number > 0 && url) { + return { number, url } + } + } catch { + // Fall through to URL parsing for older gh versions without --json support. + } + // Why: match any host (not just github.com) so a GHES PR URL still parses (#8312). + const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) + if (!urlMatch) { + return null + } + return { number: Number(urlMatch[1]), url: urlMatch[0] } +} diff --git a/src/main/github/client/create/pull-request-template.ts b/src/main/github/client/create/pull-request-template.ts new file mode 100644 index 00000000000..7f53d429d90 --- /dev/null +++ b/src/main/github/client/create/pull-request-template.ts @@ -0,0 +1,83 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../../../source-control/hosted-review-git-options' +import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +export async function findOpenPRByHeadBase(args: { + repoPath: string + repo: GitHubApiRepository + head: string + base: string + connectionId?: string | null + options?: HostedReviewExecutionOptions +}): Promise<{ number: number; url: string } | null> { + const context = githubRepoContext(args.repoPath, args.connectionId) + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'list', + '--repo', + `${args.repo.owner}/${args.repo.repo}`, + '--head', + args.head, + '--base', + args.base, + '--state', + 'open', + '--limit', + '2', + '--json', + 'number,url' + ], + { + ...ghRepoExecOptions(context), + ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)), + ...githubHostExecOptions(args.repo) + } + ) + const list = JSON.parse(stdout) as { number?: number; url?: string }[] + if (list.length !== 1 || !list[0]?.number || !list[0]?.url) { + return null + } + return { number: list[0].number, url: list[0].url } +} + +export async function readPullRequestTemplate( + repoPath: string, + connectionId?: string | null +): Promise { + const relativeCandidates = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md' + ] + const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined + if (connectionId && !remoteProvider) { + return '' + } + for (const relativeCandidate of relativeCandidates) { + try { + if (remoteProvider) { + const result = await remoteProvider.readFile( + joinWorktreeRelativePath(repoPath, relativeCandidate) + ) + if (result.isBinary) { + continue + } + return result.content + } + return await readFile(join(repoPath, relativeCandidate), 'utf8') + } catch { + // Try the next conventional PR template path. + } + } + return '' +} diff --git a/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts b/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts new file mode 100644 index 00000000000..c1505d80d55 --- /dev/null +++ b/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts @@ -0,0 +1,38 @@ +import type { OwnerRepo } from '../../gh-utils' +import type { GhExecOptions } from './../github-exec-scope' +import { detectRepositoryMergeMetadata } from './repository-merge-metadata' +import type { MainWorkItem } from './../map/work-item-field-coercion' +export async function hydrateWorkItemRepositoryMergeMetadata( + items: MainWorkItem[], + ownerRepo: OwnerRepo | null, + ghOptions: GhExecOptions, + executionScope?: string +): Promise { + const hasPullRequest = items.some((item) => item.type === 'pr') + if (!ownerRepo || !hasPullRequest) { + return items + } + // Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out. + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + undefined, + ghOptions, + executionScope + ) + 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 + ) +} diff --git a/src/main/github/client/detect/repository-merge-metadata-cache.ts b/src/main/github/client/detect/repository-merge-metadata-cache.ts new file mode 100644 index 00000000000..15d74740ca8 --- /dev/null +++ b/src/main/github/client/detect/repository-merge-metadata-cache.ts @@ -0,0 +1,56 @@ +import type { GitHubPRMergeMethodSettings } from '../../../../shared/github/pull-request-types' +export const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 + +export const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 + +export const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256 + +export type GitHubRepositoryMergeMetadata = { + mergeQueueRequired: boolean | null + autoMergeAllowed: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings +} + +export const repositoryMergeMetadataCache = new Map< + string, + { value: GitHubRepositoryMergeMetadata; expiresAt: number } +>() + +export 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) + } +} + +export function cacheRepositoryMergeMetadata( + cacheKey: string, + value: GitHubRepositoryMergeMetadata, + ttlMs: number +): void { + const now = Date.now() + pruneRepositoryMergeMetadataCache(now) + // Why: merge metadata is keyed by user-controlled branch names; keep the cache bounded across many short-lived branches. + repositoryMergeMetadataCache.delete(cacheKey) + repositoryMergeMetadataCache.set(cacheKey, { + value, + expiresAt: now + ttlMs + }) + pruneRepositoryMergeMetadataCache(now) +} + +export function _resetMergeQueueCacheForTests(): void { + repositoryMergeMetadataCache.clear() +} + +export function _getMergeQueueCacheSizeForTests(): number { + return repositoryMergeMetadataCache.size +} diff --git a/src/main/github/client/detect/repository-merge-metadata.ts b/src/main/github/client/detect/repository-merge-metadata.ts new file mode 100644 index 00000000000..a7f8f8179d4 --- /dev/null +++ b/src/main/github/client/detect/repository-merge-metadata.ts @@ -0,0 +1,121 @@ +import { normalizeGitHubPRMergeMethodSettings } from '../../../../shared/github/pull-request-merge-methods' +import { ghExecFileAsync } from '../../gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +import type { GhExecOptions } from './../github-exec-scope' +import { + MERGE_QUEUE_CACHE_TTL_MS, + MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS, + repositoryMergeMetadataCache, + pruneRepositoryMergeMetadataCache, + cacheRepositoryMergeMetadata, + type GitHubRepositoryMergeMetadata +} from './repository-merge-metadata-cache' +export async function detectRepositoryMergeMetadata( + ownerRepo: GitHubApiRepository, + branchName: string | undefined, + ghOptions: GhExecOptions, + executionScope: string | undefined = 'default' +): Promise { + const cacheKey = `${executionScope ?? 'default'}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` + pruneRepositoryMergeMetadataCache() + const cached = repositoryMergeMetadataCache.get(cacheKey) + if (cached) { + return cached.value + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + return { mergeQueueRequired: null, autoMergeAllowed: null } + } + const query = branchName + ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) { + repository(owner: $owner, name: $repo) { + viewerDefaultMergeMethod + mergeCommitAllowed + rebaseMergeAllowed + squashMergeAllowed + autoMergeAllowed + mergeQueue(branch: $branch) { id } + ref(qualifiedName: $qualified) { + rules(first: 50) { nodes { type } } + } + } + }` + : `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + viewerDefaultMergeMethod + mergeCommitAllowed + rebaseMergeAllowed + squashMergeAllowed + autoMergeAllowed + } + }` + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + const args = [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}` + ] + if (branchName) { + args.push('-f', `branch=${branchName}`) + args.push('-f', `qualified=refs/heads/${branchName}`) + } + const { stdout } = await ghExecFileAsync(args, { + ...ghOptions, + ...githubHostExecOptions(ownerRepo) + }) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + viewerDefaultMergeMethod?: unknown + mergeCommitAllowed?: unknown + rebaseMergeAllowed?: unknown + squashMergeAllowed?: unknown + autoMergeAllowed?: unknown + mergeQueue?: { id?: unknown } | null + ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null + } | null + } + } + const repository = parsed.data?.repository + const mergeMethodSettings = repository + ? normalizeGitHubPRMergeMethodSettings({ + defaultMethod: repository.viewerDefaultMergeMethod, + mergeCommitAllowed: repository.mergeCommitAllowed, + rebaseMergeAllowed: repository.rebaseMergeAllowed, + squashMergeAllowed: repository.squashMergeAllowed + }) + : undefined + const value: GitHubRepositoryMergeMetadata = { + mergeQueueRequired: branchName + ? Boolean(repository?.mergeQueue) || + Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE')) + : null, + autoMergeAllowed: + typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, + ...(mergeMethodSettings ? { mergeMethodSettings } : {}) + } + // Why: a payload without `repository` is all-unknown; keep it on the short TTL so it retries once the condition clears. + cacheRepositoryMergeMetadata( + cacheKey, + value, + repository ? MERGE_QUEUE_CACHE_TTL_MS : MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS + ) + return value + } catch { + // Why: cache a conservative result for failed merge-queue probes so we don't retry GraphQL on every poll while GitHub/network is unhappy. + const value: GitHubRepositoryMergeMetadata = { + mergeQueueRequired: null, + autoMergeAllowed: null + } + cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) + return value + } +} diff --git a/src/main/github/client/fetch/authenticated-viewer.ts b/src/main/github/client/fetch/authenticated-viewer.ts new file mode 100644 index 00000000000..dd297addd67 --- /dev/null +++ b/src/main/github/client/fetch/authenticated-viewer.ts @@ -0,0 +1,28 @@ +import type { GitHubViewer } from '../../../../shared/github/pull-request-types' +import { execFileAsync, acquire, release } from '../../gh-utils' +/** + * Get the authenticated GitHub viewer when gh is available and logged in. + * Returns null when gh is unavailable, unauthenticated, or the lookup fails. + */ +export async function getAuthenticatedViewer(): Promise { + 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() + } +} diff --git a/src/main/github/client/fetch/get-pr-comments.ts b/src/main/github/client/fetch/get-pr-comments.ts new file mode 100644 index 00000000000..17ba9175a00 --- /dev/null +++ b/src/main/github/client/fetch/get-pr-comments.ts @@ -0,0 +1,279 @@ +import type { PRComment } from '../../../../shared/github/comment-types' +import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from '../../comment-reactions' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit' +import { REVIEW_THREADS_QUERY } from './pr-review-threads-query' +/** + * Get all comments on a PR — both top-level conversation comments and inline + * review comments (including suggestions). Uses GraphQL for review threads + * to get resolution status, REST for issue-level comments. + */ +export async function getPRComments( + repoPath: string, + prNumber: number, + options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null }, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + options?.prRepo, + connectionId, + localGitOptions + ) + if (connectionId && !ownerRepo) { + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) + } + if (ownerRepo) { + await assertRateLimitBudget('core', ownerRepo, ghOptions) + } + await acquire() + try { + if (ownerRepo) { + // Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data. + const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] + const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}` + + // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero. + const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> + if (reviewThreadsGuard.blocked) { + reviewThreadsFetch = Promise.resolve(null) + } else { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + reviewThreadsFetch = ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${REVIEW_THREADS_QUERY}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-F', + `pr=${prNumber}` + ], + ghOptions + ) + } + const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([ + ghExecFileAsync( + ['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`], + ghOptions + ), + reviewThreadsFetch, + // Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads. + ghExecFileAsync( + ['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`], + ghOptions + ) + ]) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) + + // Parse issue comments (REST) + type RESTComment = { + id: number + user: { login: string; avatar_url: string; type?: string } | null + body: string + created_at: string + html_url: string + } + let issueComments: PRComment[] = [] + if (issueResult.status === 'fulfilled') { + issueComments = (JSON.parse(issueResult.value.stdout) as RESTComment[]).map( + (c): PRComment => ({ + id: c.id, + author: c.user?.login ?? 'ghost', + authorAvatarUrl: c.user?.avatar_url ?? '', + body: c.body ?? '', + createdAt: c.created_at, + url: c.html_url, + isBot: c.user?.type === 'Bot' + }) + ) + } else { + console.warn('Failed to fetch issue comments:', issueResult.reason) + } + + // Parse review threads (GraphQL) + type GQLThread = { + id: string + isResolved: boolean + line: number | null + startLine: number | null + originalLine: number | null + originalStartLine: number | null + comments: { + nodes: { + id: string + databaseId: number + author: { __typename?: string; login: string; avatarUrl: string } | null + body: string + createdAt: string + url: string + path: string + reactionGroups?: GitHubGraphQLReactionGroup[] | null + }[] + } + } + type GQLIssueComment = { + id: string + databaseId: number + author: { __typename?: string; login: string; avatarUrl: string } | null + body: string + createdAt: string + url: string + reactionGroups?: GitHubGraphQLReactionGroup[] | null + } + let graphQLReviewSummaries: PRComment[] | undefined + const reviewComments: PRComment[] = [] + if (threadsResult.status === 'fulfilled' && threadsResult.value) { + const threadsData = JSON.parse(threadsResult.value.stdout) as { + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { nodes?: GQLThread[] | null } | null + comments?: { nodes?: GQLIssueComment[] | null } | null + reviews?: { nodes?: GQLIssueComment[] | null } | null + } | null + } | null + } | null + } + // Why: graphql can exit 0 with data.repository null plus an errors array (scopes, field-level denial); + // dereferencing it would throw and drop the REST halves fetched alongside it. + const pullRequest = threadsData.data?.repository?.pullRequest + if (!pullRequest) { + console.warn('Review threads response missing pullRequest; keeping REST results') + } + const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map( + (c): PRComment => ({ + id: c.databaseId, + author: c.author?.login ?? 'ghost', + authorAvatarUrl: c.author?.avatarUrl ?? '', + body: c.body ?? '', + createdAt: c.createdAt, + url: c.url, + isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, + reactions: mapGraphQLReactionGroups(c.reactionGroups) + }) + ) + if (graphQLIssueComments.length > 0) { + issueComments = graphQLIssueComments + } + // Why: leave undefined when the payload is incomplete so the REST review summaries stay in use. + graphQLReviewSummaries = pullRequest + ? (pullRequest.reviews?.nodes ?? []) + .filter((review) => review.body?.trim()) + .map( + (review): PRComment => ({ + id: review.databaseId, + author: review.author?.login ?? 'ghost', + authorAvatarUrl: review.author?.avatarUrl ?? '', + body: review.body, + createdAt: review.createdAt, + url: review.url, + isBot: review.author?.__typename === 'Bot', + reactionSubjectId: review.id, + reactions: mapGraphQLReactionGroups(review.reactionGroups) + }) + ) + : undefined + + const threads = pullRequest?.reviewThreads?.nodes ?? [] + for (const thread of threads) { + for (const c of thread.comments.nodes) { + reviewComments.push({ + id: c.databaseId, + author: c.author?.login ?? 'ghost', + authorAvatarUrl: c.author?.avatarUrl ?? '', + body: c.body ?? '', + createdAt: c.createdAt, + url: c.url, + isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, + reactions: mapGraphQLReactionGroups(c.reactionGroups), + path: c.path, + threadId: thread.id, + isResolved: thread.isResolved, + isOutdated: thread.line == null, + // Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers. + line: thread.line ?? thread.originalLine ?? undefined, + startLine: thread.startLine ?? thread.originalStartLine ?? undefined + }) + } + } + } else { + if (threadsResult.status === 'rejected') { + console.warn('Failed to fetch review threads:', threadsResult.reason) + } + } + + // Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise. + type RESTReview = { + id: number + user: { login: string; avatar_url: string; type?: string } | null + body: string + state: string + submitted_at: string + html_url: string + } + let reviewSummaries: PRComment[] = [] + if (graphQLReviewSummaries) { + reviewSummaries = graphQLReviewSummaries + } else if (reviewsResult.status === 'fulfilled') { + reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[]) + .filter((r) => r.body?.trim()) + .map( + (r): PRComment => ({ + id: r.id, + author: r.user?.login ?? 'ghost', + authorAvatarUrl: r.user?.avatar_url ?? '', + body: r.body, + createdAt: r.submitted_at, + url: r.html_url, + isBot: r.user?.type === 'Bot' + }) + ) + } else { + console.warn('Failed to fetch review summaries:', reviewsResult.reason) + } + + const all = [...issueComments, ...reviewComments, ...reviewSummaries] + all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + return all + } + + // Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments) + const { stdout } = await ghExecFileAsync( + ['pr', 'view', String(prNumber), '--json', 'comments'], + ghOptions + ) + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + const data = JSON.parse(stdout) as { + comments: { + author: { login: string } + body: string + createdAt: string + url: string + }[] + } + return (data.comments ?? []).map((c, i) => ({ + id: i, + author: c.author?.login ?? 'ghost', + authorAvatarUrl: '', + body: c.body ?? '', + createdAt: c.createdAt, + url: c.url ?? '' + })) + } catch (err) { + console.warn('getPRComments failed:', err) + return [] + } finally { + release() + } +} diff --git a/src/main/github/client/fetch/get-work-item.ts b/src/main/github/client/fetch/get-work-item.ts new file mode 100644 index 00000000000..3178a4c921a --- /dev/null +++ b/src/main/github/client/fetch/get-work-item.ts @@ -0,0 +1,144 @@ +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import { acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils' +import { + resolveGitHubApiRepository, + resolveGitHubApiRepositoryCandidates, + resolveIssueGitHubApiRepositorySource, + type GitHubApiRepository +} from '../../github-api-repository' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import type { MainWorkItem } from './../map/work-item-field-coercion' +import { + fetchIssueWorkItem, + fetchPullRequestWorkItem, + fetchPullRequestWorkItemFromCandidates +} from './work-item-fetch' +export async function getWorkItem( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference +): Promise { + await acquire() + try { + // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number + // must share that preference so origin/upstream toggles cannot disagree. + if (type === 'issue') { + const { source } = await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + // Why: explicit origin with no origin identity must not bare-lookup ambient gh + // (same fail-closed rule as origin-pinned PR candidate resolution). + if (!source && preference === 'origin') { + return null + } + return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions) + } + if (type === 'pr') { + return await fetchPullRequestWorkItemFromCandidates( + repoPath, + number, + connectionId, + localGitOptions, + preference + ) + } + + try { + const { source } = await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + if (source || preference !== 'origin') { + const issue = await fetchIssueWorkItem( + repoPath, + source, + number, + connectionId, + localGitOptions + ) + if (issue) { + return issue + } + } + } catch (err) { + // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR. + const stderr = err instanceof Error ? err.message : String(err) + if (classifyGhError(stderr).type !== 'not_found') { + throw err + } + } + return await fetchPullRequestWorkItemFromCandidates( + repoPath, + number, + connectionId, + localGitOptions, + preference + ) + } catch { + return null + } finally { + release() + } +} + +export async function getWorkItemByOwnerRepo( + repoPath: string, + ownerRepo: GitHubApiRepository, + number: number, + type: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const requestedHost = ownerRepo.host?.trim().toLowerCase() + const requestedRepository = requestedHost + ? { ...ownerRepo, host: requestedHost } + : await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions) + if (!requestedRepository) { + return null + } + const { candidates } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + const requestedKey = githubRepoIdentityKey(requestedRepository) + const matchedRepository = candidates.find( + (candidate) => githubRepoIdentityKey(candidate) === requestedKey + ) + // Why: this lookup is reachable from pasted links. Restricting it to a + // configured remote prevents gh from sending credentials to an arbitrary host. + if (!matchedRepository) { + return null + } + await acquire() + try { + if (type === 'issue') { + return await fetchIssueWorkItem( + repoPath, + matchedRepository, + number, + connectionId, + localGitOptions + ) + } + return await fetchPullRequestWorkItem( + repoPath, + matchedRepository, + number, + connectionId, + localGitOptions + ) + } catch { + return null + } finally { + release() + } +} diff --git a/src/main/github/client/fetch/orca-star.ts b/src/main/github/client/fetch/orca-star.ts new file mode 100644 index 00000000000..ddf63d71c17 --- /dev/null +++ b/src/main/github/client/fetch/orca-star.ts @@ -0,0 +1,49 @@ +import { execFileAsync, acquire, release } from '../../gh-utils' +export const ORCA_REPO = 'stablyai/orca' + +/** + * Check if the authenticated user has starred the Orca repo. + * Returns true if starred, false if not, null if unable to determine (gh unavailable). + */ +export async function checkOrcaStarred(): Promise { + await acquire() + try { + const { stdout, stderr } = await execFileAsync( + 'gh', + ['api', '--include', `user/starred/${ORCA_REPO}`], + { encoding: 'utf-8' } + ) + const response = `${stdout ?? ''}\n${stderr ?? ''}` + if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) { + return true + } + return null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + // 404 means the user hasn't starred — the only expected "no" answer + if (message.includes('HTTP 404')) { + return false + } + // Anything else (gh not installed, not authenticated, network issue) + return null + } finally { + release() + } +} + +/** + * Star the Orca repo for the authenticated user. + */ +export async function starOrca(): Promise { + await acquire() + try { + await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], { + encoding: 'utf-8' + }) + return true + } catch { + return false + } finally { + release() + } +} diff --git a/src/main/github/client/fetch/pr-review-threads-query.ts b/src/main/github/client/fetch/pr-review-threads-query.ts new file mode 100644 index 00000000000..c0eb18f53ee --- /dev/null +++ b/src/main/github/client/fetch/pr-review-threads-query.ts @@ -0,0 +1,70 @@ +// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them). +export const REVIEW_THREADS_QUERY = ` +query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 100) { + nodes { + id + isResolved + line + startLine + originalLine + originalStartLine + comments(first: 100) { + nodes { + id + databaseId + author { __typename login avatarUrl(size: 48) } + body + createdAt + url + path + reactionGroups { + content + viewerHasReacted + reactors { + totalCount + } + } + } + } + } + } + comments(first: 100) { + nodes { + id + databaseId + author { __typename login avatarUrl(size: 48) } + body + createdAt + url + reactionGroups { + content + viewerHasReacted + reactors { + totalCount + } + } + } + } + reviews(first: 100) { + nodes { + id + databaseId + author { __typename login avatarUrl(size: 48) } + body + createdAt + url + reactionGroups { + content + viewerHasReacted + reactors { + totalCount + } + } + } + } + } + } +}` diff --git a/src/main/github/client/fetch/repo-slug-upstream.ts b/src/main/github/client/fetch/repo-slug-upstream.ts new file mode 100644 index 00000000000..0e67b06b4e3 --- /dev/null +++ b/src/main/github/client/fetch/repo-slug-upstream.ts @@ -0,0 +1,84 @@ +import { ghExecFileAsync, acquire, release, type OwnerRepo } from '../../gh-utils' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../../../source-control/hosted-review-git-options' +import { + getGitHubApiRepositoryForRemote, + getOriginGitHubApiRepository, + githubRepositorySlugArg, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from '../../github-api-repository' +import { hostedReviewLocalGitOptionArgs, sameOwnerRepo } from './../github-exec-scope' +export async function getRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getOriginGitHubApiRepository( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) +} + +/** + * Resolve a fork's upstream/parent owner/repo, or null when not a fork. + * Why: drives the fork indicator, and a same-name fork's avatar prefers the + * upstream owner (a renamed fork keeps its own owner). + * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null. + */ +export async function getRepoUpstream( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + undefined, + connectionId, + localGitOptions + ) + if (!origin) { + return null + } + const upstreamRemote = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) { + return upstreamRemote + } + await acquire() + try { + // Why: positional slugs bypass the runner's --repo qualifier, so the slug + // itself must carry the Enterprise host. + const { stdout } = await ghExecFileAsync( + ['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'], + // Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation. + { + ...ghOptions, + timeout: 10_000 + } + ) + const data = JSON.parse(stdout) as { + isFork?: boolean + parent?: { name?: string; owner?: { login?: string } } | null + } + const owner = data.parent?.owner?.login + const repo = data.parent?.name + // Why: a fork parent lives on the same server as the fork. + return data.isFork && owner && repo + ? { owner, repo, ...(origin.host ? { host: origin.host } : {}) } + : null + } catch { + return null + } finally { + release() + } +} diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts new file mode 100644 index 00000000000..aa4dd0794a8 --- /dev/null +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -0,0 +1,199 @@ +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import { + ghExecFileAsync, + classifyGhError, + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions +} from '../../gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import type { GhExecOptions } from './../github-exec-scope' +import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates' +import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import { + WORK_ITEM_PR_DETAIL_JSON_FIELDS, + usersFromUnknown, + latestReviewsFromUnknown, + type MainWorkItem +} from './../map/work-item-field-coercion' +import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item' +export async function fetchIssueWorkItem( + repoPath: string, + ownerRepo: GitHubApiRepository | null, + number: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (ownerRepo) { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], + ghOptions + ) + const item = JSON.parse(stdout) as Record + if ('pull_request' in item) { + return null + } + return mapIssueWorkItem(item) + } + + if (connectionId) { + // Why: SSH-backed gh has no repository cwd. A bare lookup could honor the + // local process GH_REPO/GH_HOST and return an unrelated repository item. + return null + } + + const { stdout } = await ghExecFileAsync( + ['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'], + ghOptions + ) + return mapIssueWorkItem(JSON.parse(stdout) as Record) +} + +// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty. +export const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' + +export async function fetchPullRequestReviewFields( + number: number, + ownerRepo: GitHubApiRepository | null, + ghOptions: GhExecOptions +): Promise> { + try { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_REVIEW_JSON_FIELDS + ] + : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS] + const { stdout } = await ghExecFileAsync(args, ghOptions) + const item = JSON.parse(stdout) as Record + return { + ...(item.reviewRequests !== undefined + ? { reviewRequests: usersFromUnknown(item.reviewRequests) } + : {}), + ...(item.latestReviews !== undefined + ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } + : {}) + } + } catch { + return {} + } +} + +export async function fetchPullRequestWorkItem( + repoPath: string, + ownerRepo: GitHubApiRepository | null, + number: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (ownerRepo) { + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_DETAIL_JSON_FIELDS + ], + ghOptions + ) + const item = JSON.parse(stdout) as Record + const mapped = mapPullRequestWorkItem(item, ownerRepo) + // Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists. + const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined + try { + const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) + return { + ...mapped, + mergeQueueRequired: mergeMetadata.mergeQueueRequired, + ...(mergeMetadata.autoMergeAllowed !== null + ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } + : {}), + ...(mergeMetadata.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) + } + } catch { + return mapped + } + } catch { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], + ghOptions + ) + const mapped = mapPullRequestWorkItem( + JSON.parse(stdout) as Record, + ownerRepo + ) + const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions) + return { ...mapped, ...reviewFields } + } + } + + if (connectionId) { + // Why: connection-backed gh cannot infer a repository from cwd. Refuse a + // bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup. + return null + } + + const { stdout } = await ghExecFileAsync( + ['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS], + ghOptions + ) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record) +} + +export async function fetchPullRequestWorkItemFromCandidates( + repoPath: string, + number: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference +): Promise { + const candidates = await resolvePullRequestLookupCandidates( + repoPath, + preference, + connectionId, + localGitOptions + ) + if (candidates.length === 0) { + if (preference === 'origin') { + return null + } + return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions) + } + for (const candidate of candidates) { + try { + return await fetchPullRequestWorkItem( + repoPath, + candidate, + number, + connectionId, + localGitOptions + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const classification = classifyGhError(message).type + if (classification !== 'not_found' && classification !== 'permission_denied') { + throw err + } + } + } + return null +} diff --git a/src/main/github/client/gh-error-predicates.ts b/src/main/github/client/gh-error-predicates.ts new file mode 100644 index 00000000000..20ffae19241 --- /dev/null +++ b/src/main/github/client/gh-error-predicates.ts @@ -0,0 +1,43 @@ +import type { PRRefreshOutcome } from '../../../shared/github/pull-request-refresh-types' +import { + classifyPRRefreshError, + safePRRefreshErrorMessage +} from '../pr-refresh-error-classification' +import { classifyGhError } from '../gh-utils' +// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions. +import { extractExecError, parseRetryAfterMs } from '../../git/exec-error' +export 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) +} + +export function isNotFoundGhError(err: unknown): boolean { + // Why: execFile keeps the API diagnostic in stderr; err.message is only "Command failed". + return classifyGhError(extractExecError(err).stderr).type === 'not_found' +} + +export function shouldStopAfterExactLookupError(err: unknown): boolean { + return classifyGhError(extractExecError(err).stderr).type !== 'not_found' +} + +export function prRefreshUpstreamError( + err: unknown +): Extract { + const errorType = classifyPRRefreshError(err) + const outcome: Extract = { + kind: 'upstream-error', + errorType, + message: safePRRefreshErrorMessage(errorType), + fetchedAt: Date.now() + } + // Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429. + if (errorType === 'rate_limited') { + const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr) + if (retryAfterMs !== null && retryAfterMs > 0) { + const retryAt = Date.now() + retryAfterMs + outcome.nextAutoRetryAt = retryAt + outcome.retryDisabledUntil = retryAt + } + } + return outcome +} diff --git a/src/main/github/client/github-exec-scope.ts b/src/main/github/client/github-exec-scope.ts new file mode 100644 index 00000000000..fbe3cd78c7a --- /dev/null +++ b/src/main/github/client/github-exec-scope.ts @@ -0,0 +1,35 @@ +import type { LocalGitExecOptions, OwnerRepo } from '../gh-utils' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions +} from '../../source-control/hosted-review-git-options' +import type { HostedReviewExecutionOptions } from '../../source-control/hosted-review-git-options' +import type { GitHubRepoExecOptions } from '../github-api-repository' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' +export type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal } + +export type HostedReviewLocalGitOptions = ReturnType + +export function hostedReviewLocalGitOptionArgs( + options: HostedReviewExecutionOptions = {} +): [] | [HostedReviewLocalGitOptions] { + return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] +} + +export function githubPRStackExecutionScope( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` +} + +export function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { + // Why: casing does not distinguish GitHub repos, but the same slug on different hosts does. + return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right)) +} + +// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks. +export function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { + const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) + return match ? { owner: match[2], repo: match[3], host: match[1] } : null +} diff --git a/src/main/github/client/list/count-work-items.ts b/src/main/github/client/list/count-work-items.ts new file mode 100644 index 00000000000..e742597685e --- /dev/null +++ b/src/main/github/client/list/count-work-items.ts @@ -0,0 +1,154 @@ +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import { isGitHubWorkItemsQueryTooLarge } from '../../../../shared/github/work-items-query-bounds' +import { parseTaskQuery, type ParsedTaskQuery } from '../../../../shared/task-query' +import { + ghExecFileAsync, + acquire, + release, + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions, + type OwnerRepo +} from '../../gh-utils' +import { + githubHostExecOptions, + resolveIssueGitHubApiRepositorySource +} from '../../github-api-repository' +import { + getRateLimit, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota +} from '../../rate-limit' +import { sameOwnerRepo } from './../github-exec-scope' +import { resolvePrWorkItemSource } from './work-item-list-request' +import { buildSearchQueryString, defaultOpenWorkItemQuery } from './work-item-search-query' +export async function countWorkItemsForQuery( + repoPath: string, + ownerRepo: OwnerRepo, + query: ParsedTaskQuery, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const searchQ = buildSearchQueryString(ownerRepo, query) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`, + '--jq', + '.total_count' + ], + ghOptions + ) + // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. + noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions) + return Number.parseInt(stdout.trim(), 10) || 0 +} + +// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total. +export async function countWorkItems( + repoPath: string, + query?: string, + preference?: IssueSourcePreference, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const trimmedQuery = query?.trim() ?? '' + if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { + return 0 + } + const [issueResolved, prResolved] = await Promise.all([ + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), + resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) + ]) + const issueOwnerRepo = issueResolved.source + const prOwnerRepo = prResolved.source + const ownerRepo = prOwnerRepo ?? issueOwnerRepo + if (!ownerRepo) { + return 0 + } + + const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null + const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + + // Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached). + if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { + await getRateLimit() + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + return 0 + } + + await acquire() + try { + if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) { + return await countWorkItemsForQuery( + repoPath, + ownerRepo, + effectiveQuery, + connectionId, + localGitOptions + ) + } + + const counts: Promise[] = [] + // Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call. + const hasPrOnlyFilter = + effectiveQuery.draft || + effectiveQuery.reviewRequested !== null || + effectiveQuery.reviewedBy !== null + if ( + effectiveQuery.scope !== 'pr' && + effectiveQuery.state !== 'merged' && + !hasPrOnlyFilter && + issueOwnerRepo + ) { + counts.push( + countWorkItemsForQuery( + repoPath, + issueOwnerRepo, + { ...effectiveQuery, scope: 'issue' }, + connectionId, + localGitOptions + ) + ) + } + if (effectiveQuery.scope !== 'issue' && prOwnerRepo) { + counts.push( + countWorkItemsForQuery( + repoPath, + prOwnerRepo, + { ...effectiveQuery, scope: 'pr' }, + connectionId, + localGitOptions + ) + ) + } + // Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves. + const results = await Promise.allSettled(counts) + let total = 0 + for (const r of results) { + if (r.status === 'fulfilled') { + total += r.value + } else { + console.warn('countWorkItems partial failure:', r.reason) + } + } + return total + } catch (err) { + console.warn('countWorkItems failed:', err) + return 0 + } finally { + release() + } +} diff --git a/src/main/github/client/list/list-work-items.ts b/src/main/github/client/list/list-work-items.ts new file mode 100644 index 00000000000..e28037a0233 --- /dev/null +++ b/src/main/github/client/list/list-work-items.ts @@ -0,0 +1,85 @@ +import type { ListWorkItemsResult } from '../../../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import { isGitHubWorkItemsQueryTooLarge } from '../../../../shared/github/work-items-query-bounds' +import { parseTaskQuery } from '../../../../shared/task-query' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveIssueGitHubApiRepositorySource } from '../../github-api-repository' +import type { MainWorkItem } from './../map/work-item-field-coercion' +import { normalizeWorkItemPage, resolvePrWorkItemSource } from './work-item-list-request' +import { listRecentWorkItems, listQueriedWorkItems } from './work-item-pages' +export async function listWorkItems( + repoPath: string, + limit = 24, + query?: string, + page?: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + noCache?: boolean, + localGitOptions: LocalGitExecOptions = {} +): Promise> { + const trimmedQuery = query?.trim() ?? '' + const requestedPage = normalizeWorkItemPage(page) + if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { + return { + items: [], + sources: { + issues: null, + prs: null, + originCandidate: null, + upstreamCandidate: null + } + } + } + const [issueResolved, prResolved] = await Promise.all([ + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), + resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) + ]) + const issueOwnerRepo = issueResolved.source + const prOwnerRepo = prResolved.source + await acquire() + try { + // Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures. + const partial = !trimmedQuery + ? await listRecentWorkItems( + repoPath, + issueOwnerRepo, + prOwnerRepo, + limit, + requestedPage, + connectionId, + noCache, + localGitOptions + ) + : await listQueriedWorkItems( + repoPath, + issueOwnerRepo, + prOwnerRepo, + parseTaskQuery(trimmedQuery), + limit, + requestedPage, + connectionId, + localGitOptions + ) + + const errors = + partial.issuesError || partial.prsError + ? { + ...(partial.issuesError ? { issues: partial.issuesError } : {}), + ...(partial.prsError ? { prs: partial.prsError } : {}) + } + : undefined + return { + items: partial.items, + sources: { + issues: issueOwnerRepo, + prs: prOwnerRepo, + originCandidate: prResolved.originCandidate, + upstreamCandidate: prResolved.upstreamCandidate + }, + ...(errors ? { errors } : {}), + ...(issueResolved.fellBack ? { issueSourceFellBack: true } : {}) + } + } finally { + release() + } +} diff --git a/src/main/github/client/list/work-item-list-request.ts b/src/main/github/client/list/work-item-list-request.ts new file mode 100644 index 00000000000..2bad4599237 --- /dev/null +++ b/src/main/github/client/list/work-item-list-request.ts @@ -0,0 +1,146 @@ +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import type { ParsedTaskQuery } from '../../../../shared/task-query' +import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' +import type { LocalGitExecOptions, OwnerRepo } from '../../gh-utils' +import { + getGitHubApiRepositoryForRemote, + getOriginGitHubApiRepository +} from '../../github-api-repository' +import { WORK_ITEM_PR_LIST_JSON_FIELDS, type MainWorkItem } from './../map/work-item-field-coercion' +import { WORK_ITEM_NUMBER_SORT_QUALIFIER, quoteGitHubSearchValue } from './work-item-search-query' +export type WorkItemListRequest = { + args: string[] + offset: number +} + +export function normalizeWorkItemPage(page: number | undefined): number { + return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1 +} + +export function buildWorkItemListRequest(args: { + kind: 'issue' | 'pr' + ownerRepo: OwnerRepo + limit: number + query: ParsedTaskQuery + page: number +}): WorkItemListRequest { + const { kind, ownerRepo, limit, query, page } = args + const searchParts: string[] = [] + + if (kind === 'issue') { + searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) + } + searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') + + if (query.state === 'open') { + searchParts.push('is:open') + } else if (query.state === 'closed') { + searchParts.push('is:closed') + if (kind === 'pr') { + searchParts.push('-is:merged') + } + } else if (query.state === 'merged') { + searchParts.push('is:merged') + } + + if (kind === 'pr' && query.draft) { + searchParts.push('draft:true') + } + + if (query.assignee) { + searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) + } + if (query.author) { + searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`) + } + if (query.labels.length > 0) { + for (const label of query.labels) { + searchParts.push(`label:${quoteGitHubSearchValue(label)}`) + } + } + if (kind === 'pr' && query.reviewRequested) { + searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) + } + if (kind === 'pr' && query.reviewedBy) { + searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) + } + if (query.freeText) { + searchParts.push(query.freeText) + } + + if (kind === 'issue') { + return { + args: [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`, + '--jq', + '.items' + ], + offset: 0 + } + } + + // Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort. + searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) + const out = [ + 'pr', + 'list', + '--limit', + String(Math.min(page * limit, 1000)), + '--state', + 'all', + '--json', + WORK_ITEM_PR_LIST_JSON_FIELDS + ] + out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + out.push('--search', searchParts.join(' ')) + return { args: out, offset: (page - 1) * limit } +} + +// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485). +export type PartialWorkItemsResult = { + items: MainWorkItem[] + issuesError?: ClassifiedError + prsError?: ClassifiedError +} + +export function assertSshRepoHasResolvedGitHubSource(args: { + connectionId?: string | null + issueOwnerRepo: OwnerRepo | null + prOwnerRepo: OwnerRepo | null +}): void { + if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) { + return + } + // Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state. + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) +} + +export type ResolvedPrWorkItemSource = { + source: OwnerRepo | null + originCandidate: OwnerRepo | null + upstreamCandidate: OwnerRepo | null +} + +// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/ +// undefined keep the multi-candidate probe ordered upstream-first, matching +// resolvePrWorkItemSource list semantics. +export async function resolvePrWorkItemSource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const [originCandidate, upstreamCandidate] = await Promise.all([ + getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions) + ]) + // Why: fork-contribution PRs live on the upstream repo (the fork's own PR + // list is almost always empty), so 'auto' resolves upstream-first exactly + // like the issue side. Only an explicit 'origin' pick pins PRs to the fork. + const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate) + return { source, originCandidate, upstreamCandidate } +} diff --git a/src/main/github/client/list/work-item-pages.ts b/src/main/github/client/list/work-item-pages.ts new file mode 100644 index 00000000000..63d5e7a0098 --- /dev/null +++ b/src/main/github/client/list/work-item-pages.ts @@ -0,0 +1,241 @@ +import type { ClassifiedError } from '../../../../shared/classified-error' +import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' +import { parseTaskQuery, type ParsedTaskQuery } from '../../../../shared/task-query' +import { sortWorkItemsByNumber } from '../../../../shared/work-items' +import { + ghExecFileAsync, + classifyListIssuesError, + classifyListPrsError, + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions, + type OwnerRepo +} from '../../gh-utils' +import { githubHostExecOptions } from '../../github-api-repository' +import { githubPRStackExecutionScope } from './../github-exec-scope' +import { hydrateWorkItemRepositoryMergeMetadata } from './../detect/hydrate-work-item-merge-metadata' +import type { MainWorkItem } from './../map/work-item-field-coercion' +import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item' +import { + buildWorkItemListRequest, + assertSshRepoHasResolvedGitHubSource, + type PartialWorkItemsResult +} from './work-item-list-request' +export async function listRecentWorkItems( + repoPath: string, + issueOwnerRepo: OwnerRepo | null, + prOwnerRepo: OwnerRepo | null, + limit: number, + page: number, + connectionId?: string | null, + noCache?: boolean, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) + const recentQuery = parseTaskQuery('is:open') + const issueRequest = issueOwnerRepo + ? buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query: recentQuery, + page + }) + : null + const prRequest = prOwnerRepo + ? buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query: recentQuery, + page + }) + : null + if (noCache && issueRequest) { + issueRequest.args.splice(1, 2) + } + // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). + // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). + const [issuesSettled, prsSettled] = await Promise.allSettled([ + issueRequest && issueOwnerRepo + ? ghExecFileAsync(issueRequest.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) + : Promise.resolve({ stdout: '[]' }), + prRequest && prOwnerRepo + ? ghExecFileAsync(prRequest.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) + : Promise.resolve({ stdout: '[]' }) + ]) + + let issues: MainWorkItem[] = [] + let issuesError: ClassifiedError | undefined + if (issuesSettled.status === 'fulfilled') { + try { + issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) + // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + } catch (err) { + // Why: a malformed issue payload must not discard the successfully fetched PR half. + issuesError = classifyListIssuesError(err instanceof Error ? err.message : String(err)) + } + } else { + const stderr = + issuesSettled.reason instanceof Error + ? issuesSettled.reason.message + : String(issuesSettled.reason) + issuesError = classifyListIssuesError(stderr) + } + + let prs: MainWorkItem[] = [] + if (prsSettled.status === 'fulfilled') { + prs = (JSON.parse(prsSettled.value.stdout) as Record[]) + .slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) + prs = await hydrateWorkItemRepositoryMergeMetadata( + prs, + prOwnerRepo, + { ...ghOptions, ...githubHostExecOptions(prOwnerRepo) }, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + } else { + // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076). + // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop. + if (issuesError) { + console.warn( + 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', + issuesError.type, + issuesError.message + ) + } + throw prsSettled.reason + } + + return { + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), + issuesError + } +} + +export async function listQueriedWorkItems( + repoPath: string, + issueOwnerRepo: OwnerRepo | null, + prOwnerRepo: OwnerRepo | null, + query: ParsedTaskQuery, + limit: number, + page?: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) + const hasPrOnlyFilter = + query.state === 'merged' || + query.draft || + query.reviewRequested !== null || + query.reviewedBy !== null + const issueScope = query.scope !== 'pr' && !hasPrOnlyFilter + const prScope = query.scope !== 'issue' + let successfulRequestCount = 0 + let nonAvailabilityFailureCount = 0 + let availabilityError: unknown + let prsError: ClassifiedError | undefined + + // Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6). + const issueFetch = (async (): Promise => { + if (!issueScope) { + return { items: [] } + } + if (!issueOwnerRepo) { + return { items: [] } + } + const request = buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query, + page: page ?? 1 + }) + try { + const { stdout } = await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) + const items = (JSON.parse(stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + successfulRequestCount += 1 + return { items } + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + if (classifyGitHubUnavailable(stderr)) { + availabilityError ??= err + } else { + nonAvailabilityFailureCount += 1 + } + return { items: [], issuesError: classifyListIssuesError(stderr) } + } + })() + + const prFetch = (async (): Promise => { + if (!prScope) { + return [] + } + if (!prOwnerRepo) { + return [] + } + const request = buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query, + page: page ?? 1 + }) + try { + const { stdout } = await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) + const mapped = (JSON.parse(stdout) as Record[]) + .slice(request.offset, request.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) + const hydrated = await hydrateWorkItemRepositoryMergeMetadata( + mapped, + prOwnerRepo, + { ...ghOptions, ...githubHostExecOptions(prOwnerRepo) }, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + successfulRequestCount += 1 + if (query.state === 'closed') { + return hydrated.filter((item) => item.state !== 'merged') + } + return hydrated + } catch (err) { + console.warn('listQueriedWorkItems PRs partial failure:', err) + const stderr = err instanceof Error ? err.message : String(err) + prsError = classifyListPrsError(stderr) + if (classifyGitHubUnavailable(stderr)) { + availabilityError ??= err + } else { + nonAvailabilityFailureCount += 1 + } + return [] + } + })() + + const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) + if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) { + // Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data. + throw availabilityError + } + return { + items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), + issuesError: issueResult.issuesError, + prsError + } +} diff --git a/src/main/github/client/list/work-item-search-query.ts b/src/main/github/client/list/work-item-search-query.ts new file mode 100644 index 00000000000..979b9a124eb --- /dev/null +++ b/src/main/github/client/list/work-item-search-query.ts @@ -0,0 +1,66 @@ +import type { ParsedTaskQuery } from '../../../../shared/task-query' +// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages. +export const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' + +export function buildSearchQueryString( + ownerRepo: { owner: string; repo: string }, + query: ParsedTaskQuery +): string { + const parts: string[] = [`repo:${ownerRepo.owner}/${ownerRepo.repo}`] + if (query.scope === 'pr') { + parts.push('is:pull-request') + } else if (query.scope === 'issue') { + parts.push('is:issue') + } + if (query.state === 'open') { + parts.push('is:open') + } else if (query.state === 'closed') { + // Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge. + parts.push('is:closed') + if (query.scope !== 'issue') { + parts.push('-is:merged') + } + } else if (query.state === 'merged') { + parts.push('is:merged') + } + if (query.draft) { + parts.push('draft:true') + } + if (query.assignee) { + parts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) + } + if (query.author) { + parts.push(`author:${quoteGitHubSearchValue(query.author)}`) + } + if (query.reviewRequested) { + parts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) + } + if (query.reviewedBy) { + parts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) + } + for (const label of query.labels) { + parts.push(`label:${quoteGitHubSearchValue(label)}`) + } + if (query.freeText) { + parts.push(query.freeText) + } + return parts.join(' ') +} + +export function quoteGitHubSearchValue(value: string): string { + return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value +} + +export function defaultOpenWorkItemQuery(): ParsedTaskQuery { + return { + scope: 'all', + state: 'open', + draft: false, + assignee: null, + author: null, + reviewRequested: null, + reviewedBy: null, + labels: [], + freeText: '' + } +} diff --git a/src/main/github/client/lookup/branch-lookup-derived-data.ts b/src/main/github/client/lookup/branch-lookup-derived-data.ts new file mode 100644 index 00000000000..295b55ef630 --- /dev/null +++ b/src/main/github/client/lookup/branch-lookup-derived-data.ts @@ -0,0 +1,76 @@ +import { getPRConflictSummary } from '../../conflict-summary' +import type { ghRepoExecOptions, OwnerRepo } from '../../gh-utils' +import { hydrateGitHubPRStack } from '../../github-pr-stack' +import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import { derivePullRequestMergeable, type PullRequestLookupData } from './pull-request-lookup-data' +import { getCachedGitHubPRStackSummary } from './pr-stack-summary-cache' + +export async function derivePRRefreshData(args: { + data: PullRequestLookupData + dataRepo: OwnerRepo | null + repoPath: string + connectionId?: string | null + localGitOptions: { wslDistro?: string } + ghOptions: ReturnType + executionScope: string + usedExactNumberLookup: boolean +}): Promise<{ + mergeable: ReturnType + stack: PullRequestLookupData['stack'] + stackMergeQueueRequired: boolean | null | undefined + conflictSummary: Awaited> +}> { + const { data, dataRepo, repoPath, connectionId, localGitOptions, ghOptions, executionScope } = + args + if (!data.stackMetadataChecked && dataRepo && args.usedExactNumberLookup) { + try { + data.stack = await getCachedGitHubPRStackSummary( + dataRepo, + data.number, + ghOptions, + executionScope + ) + data.stackMetadataChecked = true + } catch { + // Stack metadata is additive; exact PR lookup remains usable without it. + } + } + const mergeable = derivePullRequestMergeable(data) + const stack = + data.stack && dataRepo + ? await hydrateGitHubPRStack( + dataRepo, + data.number, + data.stack, + ghOptions, + data.updatedAt, + executionScope + ) + : data.stack + const stackMergeQueueRequired = + stack && dataRepo + ? ( + await detectRepositoryMergeMetadata( + dataRepo, + stack.baseRefName, + ghOptions, + executionScope + ) + ).mergeQueueRequired + : undefined + const conflictSummary = + !connectionId && + mergeable === 'CONFLICTING' && + data.baseRefName && + data.baseRefOid && + data.headRefOid + ? await getPRConflictSummary( + repoPath, + data.baseRefName, + data.baseRefOid, + data.headRefOid, + localGitOptions + ) + : undefined + return { mergeable, stack, stackMergeQueueRequired, conflictSummary } +} diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts new file mode 100644 index 00000000000..2efe7d9cd25 --- /dev/null +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -0,0 +1,294 @@ +import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types' +import type { ghRepoExecOptions, OwnerRepo } from '../../gh-utils' +import { + isCommitPartOfMergedPR, + type MergedPRCommitMembership +} from '../../merged-pr-commit-membership' +import { shouldHideNonOpenReviewOnDefaultBranch } from '../../../source-control/repo-default-branch' +import { + getGitHubApiRepositoryForRemote, + resolveGitHubApiRepositoryCandidates +} from '../../github-api-repository' +import { mapPRState } from '../../mappers' +import { noteRepositoryRateLimitSpend } from '../../rate-limit' +import { ownerRepoFromPullRequestUrl } from './../github-exec-scope' +import { prRefreshUpstreamError } from './../gh-error-predicates' +import { + isMergedImplicitPR, + shouldHideMergedImplicitPR, + getCurrentHeadOid, + type PullRequestLookupData, + type GitHubPRBranchLookupOptions +} from './pull-request-lookup-data' +import { lookupPRByBranchName } from './pr-branch-lookup' +import { lookupPRByNumber } from './pr-number-lookup' +import { derivePRRefreshData } from './branch-lookup-derived-data' +import { assemblePRRefreshFoundOutcome } from './pr-refresh-outcome-assembly' +import { shouldRetryTrackedUpstreamBranch } from './tracked-upstream-cache' +import { getTrackedUpstreamBranch } from './tracked-upstream-branch' +import { PR_BRANCH_LOOKUP_BUCKETS } from './pr-lookup-rate-limit' +import type { HostedReviewLocalGitOptions } from './../github-exec-scope' +export async function resolvePRForBranchOutcome(input: { + repoPath: string + branchName: string + linkedPRNumber?: number | null + connectionId?: string | null + fallbackPRNumber?: number | null + options: GitHubPRBranchLookupOptions + localGitOptions: HostedReviewLocalGitOptions + ghOptions: ReturnType + executionScope: string +}): Promise { + const { + repoPath, + branchName, + linkedPRNumber, + connectionId, + fallbackPRNumber, + options, + localGitOptions, + ghOptions, + executionScope + } = input + const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + // Why: connection-backed gh runs without a repository cwd. A bare lookup + // here can honor process GH_REPO/GH_HOST and return an unrelated PR. + if (connectionId && candidates.length === 0) { + return { kind: 'no-pr', fetchedAt: Date.now() } + } + // Why (#11532): account every lookup, not just the coordinator's queue — + // `hostedReview:forBranch` reaches this directly from renderer polling and + // was spending the shared quota invisibly. headRepo is `origin`, the same + // identity the coordinator guards on. + for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) { + noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions) + } + let data: PullRequestLookupData | null = null + let dataRepo: OwnerRepo | null = null + let dataHeadRepo: OwnerRepo | null = headRepo + let pendingBranchLookupError: unknown + let hasPendingBranchLookupError = false + let currentHeadOidForMergedImplicit: string | null | undefined + let usedExactNumberLookup = false + + const explicitCurrentHeadOid = + typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 + ? options.currentHeadOid.trim() + : null + let confirmedContainedHeadOid: string | null = null + let headDivergedFromMergedPRAtOid: string | null = null + const mergedPRContainsHead = async ( + candidate: PullRequestLookupData, + candidateRepo: OwnerRepo | null, + headOid: string | null + ): Promise => { + if (!candidateRepo || !headOid) { + return 'unknown' + } + const membership = await isCommitPartOfMergedPR({ + ownerRepo: candidateRepo, + prNumber: candidate.number, + commitOid: headOid, + ghOptions + }) + if (membership === 'contained') { + confirmedContainedHeadOid = headOid + } + return membership + } + const recordLinkedMergedPRDivergence = async ( + candidate: PullRequestLookupData | null, + candidateRepo: OwnerRepo | null + ): Promise => { + if ( + typeof linkedPRNumber !== 'number' || + !candidate || + mapPRState(candidate.state, candidate.isDraft) !== 'merged' || + explicitCurrentHeadOid === null || + candidate.headRefOid === explicitCurrentHeadOid + ) { + return + } + const membership = await mergedPRContainsHead( + candidate, + candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url), + explicitCurrentHeadOid + ) + if (membership === 'not-contained') { + // explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree. + headDivergedFromMergedPRAtOid = explicitCurrentHeadOid + } + } + const hideMergedImplicitPR = async ( + candidate: PullRequestLookupData | null, + candidateRepo: OwnerRepo | null + ) => { + if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) { + return false + } + // Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees. + currentHeadOidForMergedImplicit ??= + explicitCurrentHeadOid !== null + ? explicitCurrentHeadOid + : await getCurrentHeadOid(repoPath, connectionId, localGitOptions) + if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) { + return false + } + // Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible. + return ( + (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !== + 'contained' + ) + } + + if (typeof linkedPRNumber === 'number') { + usedExactNumberLookup = true + const exactLookup = await lookupPRByNumber({ + candidates, + number: linkedPRNumber, + ghOptions, + executionScope + }) + data = exactLookup.data + dataRepo = exactLookup.dataRepo + } else if (branchName) { + // During a rebase (detached HEAD) branch is empty; an empty --head filter makes gh return an arbitrary PR. + const branchLookup = await lookupPRByBranchName({ + candidates, + headRepo, + branchName, + ghOptions, + executionScope + }) + data = branchLookup.data + dataRepo = branchLookup.dataRepo + if ('pendingError' in branchLookup) { + pendingBranchLookupError = branchLookup.pendingError + hasPendingBranchLookupError = true + } + if (!data) { + // Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match. + const upstreamBranch = await getTrackedUpstreamBranch( + repoPath, + branchName, + connectionId, + localGitOptions + ) + if (upstreamBranch) { + const upstreamHeadRepo = + (await getGitHubApiRepositoryForRemote( + repoPath, + upstreamBranch.remoteName, + connectionId, + localGitOptions + )) ?? headRepo + if ( + upstreamHeadRepo && + shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo) + ) { + const upstreamLookup = await lookupPRByBranchName({ + candidates, + headRepo: upstreamHeadRepo, + branchName: upstreamBranch.branchName, + ghOptions, + executionScope + }) + data = upstreamLookup.data + dataRepo = upstreamLookup.dataRepo + if (!hasPendingBranchLookupError && 'pendingError' in upstreamLookup) { + pendingBranchLookupError = upstreamLookup.pendingError + hasPendingBranchLookupError = true + } + if (data) { + dataHeadRepo = upstreamHeadRepo + } + } + } + } + } + let mergedBranchLookupNumber: number | null = null + if (await hideMergedImplicitPR(data, dataRepo)) { + mergedBranchLookupNumber = data?.number ?? null + data = null + dataRepo = null + dataHeadRepo = headRepo + } + if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { + usedExactNumberLookup = true + const fallbackLookup = await lookupPRByNumber({ + candidates, + number: fallbackPRNumber, + ghOptions, + executionScope + }) + data = fallbackLookup.data + dataRepo = fallbackLookup.dataRepo + } + if (!data) { + if (hasPendingBranchLookupError) { + return prRefreshUpstreamError(pendingBranchLookupError) + } + return { kind: 'no-pr', fetchedAt: Date.now() } + } + await recordLinkedMergedPRDivergence(data, dataRepo) + const fallbackConfirmedMergedBranch = + typeof fallbackPRNumber === 'number' && + mergedBranchLookupNumber === fallbackPRNumber && + data.number === fallbackPRNumber + const explicitHeadHidesMergedImplicitPR = + explicitCurrentHeadOid !== null && + shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && + (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained' + // Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs. + const shouldPreserveMergedFallback = + !explicitHeadHidesMergedImplicitPR && + (fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true) + // Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads). + if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) { + return { kind: 'no-pr', fetchedAt: Date.now() } + } + // Why (#9171): on the default branch an implicit branch/fallback match must + // never surface a non-open PR — it overrides the merged-fallback + // preservation and merged-at-head carve-out on the trunk only. An exact + // linked lookup returns the linked number, so linked PRs are exempt. + if ( + await shouldHideNonOpenReviewOnDefaultBranch({ + state: mapPRState(data.state, data.isDraft), + reviewNumber: data.number, + linkedReviewNumber: linkedPRNumber, + branchName, + repoPath, + connectionId, + localGitOptions + }) + ) { + return { kind: 'no-pr', fetchedAt: Date.now() } + } + + const { mergeable, stack, stackMergeQueueRequired, conflictSummary } = await derivePRRefreshData({ + data, + dataRepo, + repoPath, + connectionId, + localGitOptions, + ghOptions, + executionScope, + usedExactNumberLookup + }) + + return assemblePRRefreshFoundOutcome({ + data, + dataRepo, + dataHeadRepo, + stack, + mergeable, + stackMergeQueueRequired, + confirmedContainedHeadOid, + headDivergedFromMergedPRAtOid, + conflictSummary + }) +} diff --git a/src/main/github/client/lookup/get-pr-for-branch.ts b/src/main/github/client/lookup/get-pr-for-branch.ts new file mode 100644 index 00000000000..58b53f0b435 --- /dev/null +++ b/src/main/github/client/lookup/get-pr-for-branch.ts @@ -0,0 +1,32 @@ +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { GitHubPRBranchLookupOptions } from './pull-request-lookup-data' +import { getPRForBranchOutcome } from './pr-for-branch-outcome' +/** + * Get PR info for a given branch using gh CLI. + * Returns null if gh is not installed, or no PR exists for the branch. + * + * When `linkedPRNumber` is provided, it is the source of truth. This handles + * "create from PR" worktrees whose local branch differs from the PR head ref, + * and prevents a coalesced linked-PR refresh from fanning out an unrelated + * branch lookup result to sibling aliases. + * `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is + * used only after branch lookup misses. + */ +export async function getPRForBranch( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + fallbackPRNumber?: number | null, + options: GitHubPRBranchLookupOptions = {} +): Promise { + const outcome = await getPRForBranchOutcome( + repoPath, + branch, + linkedPRNumber, + connectionId, + fallbackPRNumber, + options + ) + return outcome.kind === 'found' ? outcome.pr : null +} diff --git a/src/main/github/client/lookup/pr-branch-lookup.ts b/src/main/github/client/lookup/pr-branch-lookup.ts new file mode 100644 index 00000000000..2ee667ceacc --- /dev/null +++ b/src/main/github/client/lookup/pr-branch-lookup.ts @@ -0,0 +1,163 @@ +import { ghExecFileAsync } from '../../gh-utils' +import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import type { GhExecOptions } from './../github-exec-scope' +import { isNoPullRequestError } from './../gh-error-predicates' +import { + PR_LOOKUP_JSON_FIELDS, + PR_BRANCH_LIST_JSON_FIELDS, + mapRestPullRequest, + normalizePullRequestLookupData, + type PullRequestLookupData, + type RestPullRequest +} from './pull-request-lookup-data' +import { getPRByNumber } from './pr-number-lookup' +export async function getRestPRForBranch( + prRepo: GitHubApiRepository, + headOwner: string, + branchName: string, + ghOptions: ReturnType +): Promise { + const head = encodeURIComponent(`${headOwner}:${branchName}`) + const { stdout } = await ghExecFileAsync( + ['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`], + { ...ghOptions, ...githubHostExecOptions(prRepo) } + ) + const list = JSON.parse(stdout) as RestPullRequest[] + const pr = list[0] + return pr ? mapRestPullRequest(pr) : null +} + +export async function getFallbackPRListForBranch( + prRepo: GitHubApiRepository, + branchName: string, + ghOptions: ReturnType +): Promise { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'list', + '--repo', + `${prRepo.owner}/${prRepo.repo}`, + '--head', + branchName, + '--state', + 'all', + '--limit', + '1', + '--json', + PR_BRANCH_LIST_JSON_FIELDS + ], + { ...ghOptions, ...githubHostExecOptions(prRepo) } + ) + const list = JSON.parse(stdout) as PullRequestLookupData[] + return list[0] ?? null +} + +export async function hydrateBranchLookupWithExactPR( + ownerRepo: OwnerRepo, + branchData: PullRequestLookupData | null, + ghOptions: GhExecOptions, + executionScope: string +): Promise { + if (!branchData) { + return null + } + try { + return ( + (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? + branchData + ) + } catch { + return branchData + } +} + +export async function lookupPRByBranchName(args: { + candidates: OwnerRepo[] + headRepo: OwnerRepo | null + branchName: string + ghOptions: GhExecOptions + executionScope: string +}): Promise<{ + data: PullRequestLookupData | null + dataRepo: OwnerRepo | null + pendingError?: unknown +}> { + if (args.candidates.length > 0) { + let pendingError: unknown + let hasPendingError = false + for (const candidate of args.candidates) { + try { + const branchData = args.headRepo + ? await getRestPRForBranch( + candidate, + args.headRepo.owner, + args.branchName, + args.ghOptions + ) + : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) + // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) + if (data) { + return { data, dataRepo: candidate } + } + } catch (err) { + if (args.headRepo) { + throw err + } + if (!hasPendingError) { + pendingError = err + hasPendingError = true + } + try { + const branchData = await getRestPRForBranch( + candidate, + candidate.owner, + args.branchName, + args.ghOptions + ) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) + if (data) { + return { data, dataRepo: candidate } + } + } catch (retryErr) { + if (!hasPendingError) { + pendingError = retryErr + hasPendingError = true + } + } + } + } + // Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error. + return hasPendingError + ? { data: null, dataRepo: null, pendingError } + : { data: null, dataRepo: null } + } + + try { + const { stdout } = await ghExecFileAsync( + ['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS], + args.ghOptions + ) + return { + data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), + dataRepo: null + } + } catch (err) { + if (isNoPullRequestError(err)) { + return { data: null, dataRepo: null } + } + throw err + } +} diff --git a/src/main/github/client/lookup/pr-for-branch-outcome.ts b/src/main/github/client/lookup/pr-for-branch-outcome.ts new file mode 100644 index 00000000000..6bda22315b9 --- /dev/null +++ b/src/main/github/client/lookup/pr-for-branch-outcome.ts @@ -0,0 +1,44 @@ +import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types' +import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' +import { hostedReviewLocalGitOptionArgs, githubPRStackExecutionScope } from './../github-exec-scope' +import type { GitHubPRBranchLookupOptions } from './pull-request-lookup-data' +import { prRefreshUpstreamError } from './../gh-error-predicates' +import { resolvePRForBranchOutcome } from './branch-lookup-resolution' +export async function getPRForBranchOutcome( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + fallbackPRNumber?: number | null, + options: GitHubPRBranchLookupOptions = {} +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + // Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible. + if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') { + return { kind: 'no-pr', fetchedAt: Date.now() } + } + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(context) + const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions) + + await acquire() + try { + return await resolvePRForBranchOutcome({ + repoPath, + branchName, + linkedPRNumber, + connectionId, + fallbackPRNumber, + options, + localGitOptions, + ghOptions, + executionScope + }) + } catch (err) { + return prRefreshUpstreamError(err) + } finally { + release() + } +} diff --git a/src/main/github/client/lookup/pr-lookup-rate-limit.ts b/src/main/github/client/lookup/pr-lookup-rate-limit.ts new file mode 100644 index 00000000000..3905d27622b --- /dev/null +++ b/src/main/github/client/lookup/pr-lookup-rate-limit.ts @@ -0,0 +1,70 @@ +import { ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from '../../gh-utils' +import { getOriginGitHubApiRepository, type GitHubApiRepository } from '../../github-api-repository' +import { + getRateLimit, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota, + type RateLimitBucketKind +} from '../../rate-limit' +import type { GhExecOptions } from './../github-exec-scope' +// Why: a branch lookup prefers REST but can fall back to `gh pr list` and +// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh +// coordinator's own estimate. +export const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const + +/** + * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh + * coordinator's queue (#11532). + * + * The coordinator guards and paces its own background refreshes, but + * `hostedReview:forBranch` polls the same lookup straight from the renderer. + * Ungated, the two paths together could spend the user's entire hourly quota — + * which is per user and shared with their own `gh` and CLI agents. + * Returns the reset time when the caller must not spend, else `null`. + */ +export async function getGitHubPRLookupRateLimitBlock( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ resetAt: number } | null> { + const executionOptions = ghRepoExecOptions( + githubRepoContext(repoPath, connectionId, localGitOptions) + ) + // Why: identity resolution runs local git, which can fail for reasons that + // have nothing to do with the budget; let the lookup itself classify those. + const repository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + executionOptions + ).catch(() => null) + if (repository === null) { + return null + } + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + // Why: the probe only warms the snapshot and is exempt from limits, so a + // failure must fail open rather than block the lookup (#7553). + await getRateLimit().catch(() => undefined) + } + // Why: retrying at the earlier reset would fail again on the bucket that has + // not reset yet, so the latest blocked reset is the only honest retry time. + const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).flatMap((guard) => (guard.blocked ? [guard.resetAt] : [])) + return resets.length > 0 ? { resetAt: Math.max(...resets) } : null +} + +export async function assertRateLimitBudget( + bucket: RateLimitBucketKind, + repository?: GitHubApiRepository | null, + executionOptions?: Pick +): Promise { + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const guard = repositoryRateLimitGuard(repository, bucket, executionOptions) + if (guard.blocked) { + throw new Error( + `GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}` + ) + } +} diff --git a/src/main/github/client/lookup/pr-number-lookup.ts b/src/main/github/client/lookup/pr-number-lookup.ts new file mode 100644 index 00000000000..42a605eb295 --- /dev/null +++ b/src/main/github/client/lookup/pr-number-lookup.ts @@ -0,0 +1,152 @@ +import { ghExecFileAsync } from '../../gh-utils' +import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import { + isNoPullRequestError, + isNotFoundGhError, + shouldStopAfterExactLookupError +} from './../gh-error-predicates' +import { + PR_LOOKUP_JSON_FIELDS, + mapRestPullRequest, + normalizePullRequestLookupData, + type PullRequestLookupData, + type RestPullRequest +} from './pull-request-lookup-data' +import { hydratePullRequestLookupData } from './pull-request-lookup-hydration' +import { isGitObjectId, isUsableRestStackMetadata } from './rest-stack-metadata-validation' +export async function getRestPRByNumber( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + options: { requireUsableStackMetadata?: boolean } = {} +): Promise { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } + ) + const parsed = JSON.parse(stdout) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('invalid response shape') + } + const restData = parsed as RestPullRequest + const mapped = mapRestPullRequest(restData) + if ( + options.requireUsableStackMetadata && + restData.stack !== undefined && + restData.stack !== null + ) { + // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe. + if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) { + throw new Error('malformed stack') + } + if (!isGitObjectId(restData.head?.sha)) { + throw new Error('missing head SHA') + } + } + return mapped +} + +export async function getPRByNumber( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + executionScope: string, + knownPullRequestData?: PullRequestLookupData | null +): Promise { + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + PR_LOOKUP_JSON_FIELDS + ], + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } + ) + const exactData = JSON.parse(stdout) as PullRequestLookupData + return hydratePullRequestLookupData( + ownerRepo, + { + ...knownPullRequestData, + ...exactData, + ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) + }, + ghOptions, + executionScope + ) + } catch (err) { + // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. + if (isNotFoundGhError(err)) { + return null + } + try { + const restData = + knownPullRequestData === undefined + ? await getRestPRByNumber(ownerRepo, number, ghOptions) + : knownPullRequestData + return restData + ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) + : null + } catch (restErr) { + if (isNotFoundGhError(restErr)) { + return null + } + if (!shouldStopAfterExactLookupError(restErr)) { + return null + } + throw restErr + } + } +} + +export async function lookupPRByNumber(args: { + candidates: OwnerRepo[] + number: number + ghOptions: ReturnType + executionScope: string +}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { + for (const candidate of args.candidates) { + try { + const linkedData = await getPRByNumber( + candidate, + args.number, + args.ghOptions, + args.executionScope + ) + if (!linkedData) { + continue + } + return { data: linkedData, dataRepo: candidate } + } catch (err) { + if (shouldStopAfterExactLookupError(err)) { + throw err + } + // Candidate probing is best-effort; another repo may own the PR. + } + } + + if (args.candidates.length > 0) { + return { data: null, dataRepo: null } + } + + try { + const { stdout } = await ghExecFileAsync( + ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], + args.ghOptions + ) + return { + data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), + dataRepo: null + } + } catch (err) { + if (isNoPullRequestError(err)) { + // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo. + return { data: null, dataRepo: null } + } + throw err + } +} diff --git a/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts b/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts new file mode 100644 index 00000000000..1a494c407cc --- /dev/null +++ b/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts @@ -0,0 +1,70 @@ +import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types' +import type { + PRConflictSummary, + PRMergeableState, + GitHubPRStack +} from '../../../../shared/github/pull-request-types' +import { deriveCheckStatus, mapPRState } from '../../mappers' +import type { OwnerRepo } from '../../gh-utils' +import type { PullRequestLookupData } from './pull-request-lookup-data' + +export function assemblePRRefreshFoundOutcome(args: { + data: PullRequestLookupData + dataRepo: OwnerRepo | null + dataHeadRepo: OwnerRepo | null + stack: GitHubPRStack | undefined + mergeable: PRMergeableState + stackMergeQueueRequired: boolean | null | undefined + confirmedContainedHeadOid: string | null + headDivergedFromMergedPRAtOid: string | null + conflictSummary: PRConflictSummary | undefined +}): PRRefreshOutcome { + const { + data, + dataRepo, + dataHeadRepo, + stack, + mergeable, + stackMergeQueueRequired, + confirmedContainedHeadOid, + headDivergedFromMergedPRAtOid, + conflictSummary + } = args + return { + kind: 'found', + fetchedAt: Date.now(), + pr: { + number: data.number, + title: data.title, + state: mapPRState(data.state, data.isDraft), + url: data.url, + checksStatus: deriveCheckStatus(data.statusCheckRollup), + updatedAt: data.updatedAt, + mergeable, + ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), + ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), + ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}), + ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined + ? { + mergeQueueRequired: + stackMergeQueueRequired !== undefined + ? stackMergeQueueRequired + : data.mergeQueueRequired + } + : {}), + ...(data.mergeMethodSettings !== undefined + ? { mergeMethodSettings: data.mergeMethodSettings } + : {}), + ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), + ...(stack ? { stack } : {}), + headSha: data.headRefOid, + ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), + ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), + ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}), + ...(data.headRefName ? { headRefName: data.headRefName } : {}), + prRepo: dataRepo ?? undefined, + headRepo: dataHeadRepo ?? undefined, + conflictSummary + } + } +} diff --git a/src/main/github/client/lookup/pr-stack-summary-cache.ts b/src/main/github/client/lookup/pr-stack-summary-cache.ts new file mode 100644 index 00000000000..0c48915c1c4 --- /dev/null +++ b/src/main/github/client/lookup/pr-stack-summary-cache.ts @@ -0,0 +1,92 @@ +import type { GitHubPRStack } from '../../../../shared/github/pull-request-types' +import type { ghRepoExecOptions } from '../../gh-utils' +import type { GitHubApiRepository } from '../../github-api-repository' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { getRestPRByNumber } from './pr-number-lookup' +export const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 + +// Why: a failed probe must not masquerade as "no stack" for a full minute; retry it sooner. +export const PR_STACK_SUMMARY_FAILURE_CACHE_TTL_MS = 5_000 + +export const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 + +export const STACK_METADATA_UNAVAILABLE_ERROR = + 'Could not verify GitHub pull request stack metadata. Refresh and try again.' + +type PRStackSummaryCacheEntry = + | { ok: true; value: GitHubPRStack | undefined; expiresAt: number } + | { ok: false; error: unknown; expiresAt: number } + +export const prStackSummaryCache = new Map() + +export const prStackSummaryInFlight = new Map>() + +export function prunePRStackSummaryCache(now = Date.now()): void { + for (const [key, cached] of prStackSummaryCache) { + if (cached.expiresAt <= now) { + prStackSummaryCache.delete(key) + } + } + while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = prStackSummaryCache.keys().next().value + if (oldestKey === undefined) { + return + } + prStackSummaryCache.delete(oldestKey) + } +} + +export async function getCachedGitHubPRStackSummary( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + executionScope: string +): Promise { + const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}` + const now = Date.now() + prunePRStackSummaryCache(now) + const cached = prStackSummaryCache.get(key) + if (cached && cached.expiresAt > now) { + // Why: a cached failure must stay a failure — returning undefined would read as "this PR has no stack". + if (!cached.ok) { + throw cached.error + } + return cached.value + } + const existing = prStackSummaryInFlight.get(key) + if (existing) { + return existing + } + const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr.stack) + prStackSummaryInFlight.set(key, request) + try { + const value = await request + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + ok: true, + value, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + return value + } catch (err) { + // Why: avoid repeating a failed REST probe on every review poll, on a short cooldown. + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + ok: false, + error: err, + expiresAt: Date.now() + PR_STACK_SUMMARY_FAILURE_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + throw err + } finally { + if (prStackSummaryInFlight.get(key) === request) { + prStackSummaryInFlight.delete(key) + } + } +} + +export function _resetPRStackSummaryCacheForTests(): void { + prStackSummaryCache.clear() + prStackSummaryInFlight.clear() +} diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts new file mode 100644 index 00000000000..5b23c157672 --- /dev/null +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -0,0 +1,180 @@ +import type { + GitHubPRMergeMethodSettings, + GitHubPRStack, + PRMergeableState, + PRReviewDecision +} from '../../../../shared/github/pull-request-types' +import { gitExecFileAsync } from '../../gh-utils' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import type { HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options' +import { mapPRState } from '../../mappers' +import { + normalizePRMergeable, + normalizeReviewDecision, + isAutoMergeEnabled +} from './../map/work-item-field-coercion' +export type PullRequestLookupData = { + number: number + title: string + state: string + url: string + statusCheckRollup: unknown[] + updatedAt: string + isDraft?: boolean + mergeable: string + reviewDecision?: PRReviewDecision | null + autoMergeRequest?: unknown + autoMergeEnabled?: boolean + autoMergeAllowed?: boolean | null + mergeQueueRequired?: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings + mergeStateStatus?: string | null + baseRefName?: string + headRefName?: string + baseRefOid?: string + headRefOid?: string + stack?: GitHubPRStack + stackMetadataChecked?: boolean +} + +export type RestPullRequest = { + number: number + title: string + state: string + html_url?: string + url?: string + updated_at?: string + draft?: boolean + merged_at?: string | null + mergeable?: boolean | null + mergeable_state?: string | null + base?: { ref?: string; sha?: string } + head?: { ref?: string; sha?: string } + stack?: { + number?: number + position?: number + size?: number + base?: { ref?: string; sha?: string } + } | null +} + +export const PR_LOOKUP_JSON_FIELDS = + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + +export const PR_BRANCH_LIST_JSON_FIELDS = + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + +export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & { + acceptMergedFallbackPR?: boolean + // Why: compare merged implicit PRs against the worktree HEAD, not main repo HEAD, without a worktree-scoped git call. + currentHeadOid?: string | null +} + +export 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' +} + +export function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableState { + const mergeable = normalizePRMergeable(data.mergeable) + if (mergeable === 'CONFLICTING' || data.mergeStateStatus === 'DIRTY') { + return 'CONFLICTING' + } + return mergeable ?? 'UNKNOWN' +} + +export function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { + const stack = + typeof pr.stack?.number === 'number' && + typeof pr.stack.position === 'number' && + typeof pr.stack.size === 'number' && + typeof pr.stack.base?.ref === 'string' + ? { + number: pr.stack.number, + position: pr.stack.position, + size: pr.stack.size, + baseRefName: pr.stack.base.ref, + ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {}) + } + : undefined + return { + number: pr.number, + title: pr.title, + state: pr.merged_at ? 'MERGED' : pr.state, + url: pr.html_url ?? pr.url ?? '', + statusCheckRollup: [], + updatedAt: pr.updated_at ?? '', + isDraft: pr.draft, + mergeable: mapRestPRMergeable(pr), + baseRefName: pr.base?.ref, + headRefName: pr.head?.ref, + baseRefOid: pr.base?.sha, + headRefOid: pr.head?.sha, + stackMetadataChecked: true, + ...(stack ? { stack } : {}) + } +} + +export function isMergedImplicitPR( + data: PullRequestLookupData, + linkedPRNumber?: number | null +): boolean { + // Why: a merged PR without an explicit link is just a historical branch match, not implicit review context. + return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged' +} + +export function shouldHideMergedImplicitPR( + data: PullRequestLookupData | null, + linkedPRNumber: number | null | undefined, + currentHeadOid: string | null +): boolean { + if (!data || !isMergedImplicitPR(data, linkedPRNumber)) { + return false + } + // Why: keep hiding historical merged branch matches, but preserve the merged PR for the exact commit currently checked out. + return !currentHeadOid || data.headRefOid !== currentHeadOid +} + +export 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) + } +} + +export async function getCurrentHeadOid( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): Promise { + const provider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + if (provider) { + const result = await provider.exec(['rev-parse', 'HEAD'], repoPath) + return result.stdout.trim() || null + } + try { + const result = await gitExecFileAsync(['rev-parse', 'HEAD'], { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return result.stdout.trim() || null + } catch { + return null + } +} diff --git a/src/main/github/client/lookup/pull-request-lookup-hydration.ts b/src/main/github/client/lookup/pull-request-lookup-hydration.ts new file mode 100644 index 00000000000..bf2993a0f6d --- /dev/null +++ b/src/main/github/client/lookup/pull-request-lookup-hydration.ts @@ -0,0 +1,33 @@ +import type { OwnerRepo } from '../../gh-utils' +import type { GhExecOptions } from './../github-exec-scope' +import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import { + normalizePullRequestLookupData, + type PullRequestLookupData +} from './pull-request-lookup-data' +export async function hydratePullRequestLookupData( + ownerRepo: OwnerRepo, + data: PullRequestLookupData, + ghOptions: GhExecOptions, + executionScope: string +): Promise { + const normalized = normalizePullRequestLookupData(data) + const hasRichMergeFields = + 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data + const mergeMetadata = hasRichMergeFields + ? await detectRepositoryMergeMetadata( + ownerRepo, + normalized.stack?.baseRefName ?? normalized.baseRefName, + ghOptions, + executionScope + ) + : undefined + return { + ...normalized, + ...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}), + ...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}), + ...(mergeMetadata?.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) + } +} diff --git a/src/main/github/client/lookup/pull-request-push-target.ts b/src/main/github/client/lookup/pull-request-push-target.ts new file mode 100644 index 00000000000..3dc19733c6c --- /dev/null +++ b/src/main/github/client/lookup/pull-request-push-target.ts @@ -0,0 +1,154 @@ +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { + ghExecFileAsync, + acquire, + release, + ghRepoExecOptions, + githubRepoContext, + getRemoteUrlForRepo, + type LocalGitExecOptions +} from '../../gh-utils' +import { + getGitHubApiRepositoryForRemote, + githubHostExecOptions, + type GitHubApiRepository +} from '../../github-api-repository' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { isNotFoundGhError } from './../gh-error-predicates' +import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates' +export function pickPushRemoteUrl(args: { + originUrl: string | null + cloneUrl: string + sshUrl: string +}): string { + const { originUrl, cloneUrl, sshUrl } = args + // Why: GHES port-443 SSH uses `ssh.`, not just ssh.github.com. + if (originUrl && (/^(git@|ssh:)/.test(originUrl) || /:\/\/(?:[^@/]+@)?ssh\./.test(originUrl))) { + return sshUrl + } + return cloneUrl +} + +export function sanitizeRemoteName(owner: string, repo: string): string { + const slug = `${owner}-${repo}` + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[.-]+|[.-]+$/g, '') + return slug ? `pr-${slug}` : 'pr-head' +} + +/** + * A fork push target plus the PR's `maintainer_can_modify` flag, kept outside + * {@link GitPushTarget} so it never leaks into the persisted push-target shape. + */ +export type PullRequestPushTarget = { + pushTarget: GitPushTarget + /** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */ + maintainerCanModify?: boolean +} + +export async function getPullRequestPushTarget( + repoPath: string, + prNumber: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference +): Promise { + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(context) + const candidates = await resolvePullRequestLookupCandidates( + repoPath, + preference, + connectionId, + localGitOptions + ) + if (candidates.length === 0) { + return null + } + + await acquire() + try { + let prStdout = '' + let matchedRepository: GitHubApiRepository | null = null + for (const candidate of candidates) { + try { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`], + { ...ghOptions, ...githubHostExecOptions(candidate) } + ) + prStdout = stdout + matchedRepository = candidate + break + } catch (error) { + // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up. + if (isNotFoundGhError(error)) { + continue + } + throw error + } + } + if (!prStdout || !matchedRepository) { + return null + } + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + const pr = JSON.parse(prStdout) as { + maintainer_can_modify?: boolean + head?: { + ref?: string + repo?: { + full_name?: string + clone_url?: string + ssh_url?: string + owner?: { login?: string } + name?: string + } | null + } + } + const headRepo = pr.head?.repo + const branchName = pr.head?.ref?.trim() + const owner = headRepo?.owner?.login?.trim() + const repo = headRepo?.name?.trim() ?? headRepo?.full_name?.split('/')[1]?.trim() + const cloneUrl = headRepo?.clone_url?.trim() + const sshUrl = headRepo?.ssh_url?.trim() + const maintainerCanModify = + typeof pr.maintainer_can_modify === 'boolean' ? pr.maintainer_can_modify : undefined + if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) { + return null + } + if ( + origin && + githubRepoIdentityKey(origin) === + githubRepoIdentityKey({ owner, repo, host: matchedRepository.host }) + ) { + return { + pushTarget: { remoteName: 'origin', branchName }, + ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) + } + } + + let originUrl: string | null = null + try { + const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin') + originUrl = rawOriginUrl?.trim() || null + } catch { + originUrl = null + } + return { + pushTarget: { + remoteName: sanitizeRemoteName(owner, repo), + branchName, + remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl }) + }, + ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) + } + } finally { + release() + } +} diff --git a/src/main/github/client/lookup/rest-stack-metadata-validation.ts b/src/main/github/client/lookup/rest-stack-metadata-validation.ts new file mode 100644 index 00000000000..9837e4e0d7f --- /dev/null +++ b/src/main/github/client/lookup/rest-stack-metadata-validation.ts @@ -0,0 +1,32 @@ +export function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +export function isGitObjectId(value: unknown): value is string { + return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) +} + +export function isUsableRestStackMetadata(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const stack = value as { + number?: unknown + position?: unknown + size?: unknown + base?: unknown + } + if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) { + return false + } + const base = stack.base as { ref?: unknown; sha?: unknown } + return ( + isPositiveSafeInteger(stack.number) && + isPositiveSafeInteger(stack.position) && + isPositiveSafeInteger(stack.size) && + stack.position <= stack.size && + typeof base.ref === 'string' && + base.ref.trim().length > 0 && + (base.sha === undefined || isGitObjectId(base.sha)) + ) +} diff --git a/src/main/github/client/lookup/tracked-upstream-branch.ts b/src/main/github/client/lookup/tracked-upstream-branch.ts new file mode 100644 index 00000000000..b13b97b6fed --- /dev/null +++ b/src/main/github/client/lookup/tracked-upstream-branch.ts @@ -0,0 +1,191 @@ +import { gitExecFileAsync } from '../../gh-utils' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { readLocalGitConfigSignature } from '../../local-git-config-signature' +import { + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS, + trackedUpstreamSnapshotCache, + trackedUpstreamSnapshotInFlight, + trackedUpstreamSnapshotGenerations, + beginTrackedUpstreamSnapshotProbe, + finishTrackedUpstreamSnapshotProbe, + pruneTrackedUpstreamSnapshotCache, + parseTrackedUpstreamBranch, + getCacheableTrackedUpstreamSnapshot, + canUseCachedTrackedUpstreamBranch, + doesTrackedUpstreamCacheConfigSignatureMatch, + getTrackedUpstreamBranchCacheKey, + type TrackedUpstreamBranch, + type TrackedUpstreamSnapshotProbeResult +} from './tracked-upstream-cache' +export async function getTrackedUpstreamBranch( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): Promise { + const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions) + const now = Date.now() + const cached = trackedUpstreamSnapshotCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch( + cached, + repoPath, + connectionId, + localGitOptions + ) + if ( + configSignatureMatches && + cached.upstreamsByBranchName.has(branchName) && + canUseCachedTrackedUpstreamBranch(cached, branchName) + ) { + return cached.upstreamsByBranchName.get(branchName) ?? null + } + trackedUpstreamSnapshotCache.delete(cacheKey) + } + if (cached) { + trackedUpstreamSnapshotCache.delete(cacheKey) + } + + const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) + if (inFlight) { + const result = await inFlight + if (result.upstreamsByBranchName.has(branchName)) { + return result.upstreamsByBranchName.get(branchName) ?? null + } + // Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null. + const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) + if (retryInFlight) { + const retryResult = await retryInFlight + return retryResult.upstreamsByBranchName.get(branchName) ?? null + } + } + + // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch. + const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey) + const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions) + trackedUpstreamSnapshotInFlight.set(cacheKey, probe) + try { + const result = await probe + if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) { + trackedUpstreamSnapshotCache.set(cacheKey, { + ...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}), + upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), + expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS + }) + pruneTrackedUpstreamSnapshotCache(Date.now()) + } + if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { + const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey) + if (fresherCached?.upstreamsByBranchName.has(branchName)) { + return fresherCached.upstreamsByBranchName.get(branchName) ?? null + } + } + return result.upstreamsByBranchName.get(branchName) ?? null + } finally { + if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { + trackedUpstreamSnapshotInFlight.delete(cacheKey) + } + finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) + } +} + +export async function probeTrackedUpstreamSnapshot( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): Promise { + const startingGitConfigSignature = await readLocalGitConfigSignature({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + }) + const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches( + repoPath, + connectionId, + localGitOptions + ) + const endingGitConfigSignature = await readLocalGitConfigSignature({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + }) + const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro + const configSignatureChanged = + isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature + const gitConfigSignature = + startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined + return { + // Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick. + cacheable: !configSignatureChanged && !probeFailed, + probeFailed, + ...(gitConfigSignature ? { gitConfigSignature } : {}), + upstreamsByBranchName + } +} + +export async function probeTrackedUpstreamBranches( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): Promise<{ + probeFailed: boolean + upstreamsByBranchName: Map +}> { + const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'] + const provider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + if (provider) { + const result = await provider.exec(args, repoPath) + return { + probeFailed: false, + upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) + } + } + try { + const result = await gitExecFileAsync(args, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return { + probeFailed: false, + upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) + } + } catch { + return { probeFailed: true, upstreamsByBranchName: new Map() } + } +} + +export function parseTrackedUpstreamBranches( + stdout: string +): Map { + const upstreamsByBranchName = new Map() + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const [branchName, upstreamRef] = line.split('\0') + const localBranchName = branchName?.replace(/^refs\/heads\//, '') + if (!localBranchName) { + continue + } + upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? '')) + } + return upstreamsByBranchName +} + +export 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) +} diff --git a/src/main/github/client/lookup/tracked-upstream-cache.ts b/src/main/github/client/lookup/tracked-upstream-cache.ts new file mode 100644 index 00000000000..167bdad435d --- /dev/null +++ b/src/main/github/client/lookup/tracked-upstream-cache.ts @@ -0,0 +1,146 @@ +import { splitRemoteBranchName } from '../../../../shared/git-effective-upstream' +import type { OwnerRepo } from '../../gh-utils' +import { readLocalGitConfigSignature } from '../../local-git-config-signature' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +export type TrackedUpstreamBranch = { + remoteName: string + branchName: string +} + +export const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 + +export const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 + +export type TrackedUpstreamSnapshotCacheEntry = { + expiresAt: number + gitConfigSignature?: string + upstreamsByBranchName: Map +} + +export type TrackedUpstreamSnapshotProbeResult = { + cacheable: boolean + gitConfigSignature?: string + probeFailed: boolean + upstreamsByBranchName: Map +} + +export const trackedUpstreamSnapshotCache = new Map() + +export const trackedUpstreamSnapshotInFlight = new Map< + string, + Promise +>() + +export const trackedUpstreamSnapshotGenerations = new Map() + +export function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { + const generation = Symbol() + trackedUpstreamSnapshotGenerations.set(cacheKey, generation) + return generation +} + +export function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { + // Why: generations only guard an active probe; retaining completed keys leaks worktree/runtime identities past the snapshot TTL. + if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { + trackedUpstreamSnapshotGenerations.delete(cacheKey) + } +} + +export function pruneTrackedUpstreamSnapshotCache(now: number): void { + for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { + if (cached.expiresAt <= now) { + trackedUpstreamSnapshotCache.delete(cacheKey) + } + } + // Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound. + while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldestKey = trackedUpstreamSnapshotCache.keys().next().value + if (oldestKey === undefined) { + break + } + trackedUpstreamSnapshotCache.delete(oldestKey) + } +} + +export function _getTrackedUpstreamBranchCacheSizesForTests(): { + snapshots: number + inFlight: number + generations: number +} { + return { + snapshots: trackedUpstreamSnapshotCache.size, + inFlight: trackedUpstreamSnapshotInFlight.size, + generations: trackedUpstreamSnapshotGenerations.size + } +} + +export function __resetTrackedUpstreamBranchCacheForTests(): void { + trackedUpstreamSnapshotCache.clear() + trackedUpstreamSnapshotInFlight.clear() + trackedUpstreamSnapshotGenerations.clear() +} + +export function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null { + const parsed = splitRemoteBranchName(upstreamRef.trim()) + if (!parsed) { + return null + } + return parsed +} + +export function shouldRetryTrackedUpstreamBranch( + upstreamBranch: TrackedUpstreamBranch, + branchName: string, + upstreamHeadRepo: OwnerRepo, + headRepo: OwnerRepo | null +): boolean { + if (upstreamBranch.branchName !== branchName) { + return true + } + if (!headRepo) { + return true + } + return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo) +} + +export function getCacheableTrackedUpstreamSnapshot( + upstreamsByBranchName: Map +): Map { + // Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan. + return upstreamsByBranchName +} + +export function canUseCachedTrackedUpstreamBranch( + cached: TrackedUpstreamSnapshotCacheEntry, + branchName: string +): boolean { + return cached.upstreamsByBranchName.has(branchName) +} + +export async function doesTrackedUpstreamCacheConfigSignatureMatch( + cached: TrackedUpstreamSnapshotCacheEntry, + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): Promise { + if (!cached.gitConfigSignature) { + return true + } + const currentSignature = await readLocalGitConfigSignature({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + }) + return currentSignature === cached.gitConfigSignature +} + +export 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') +} diff --git a/src/main/github/client/map/review-comment-response.ts b/src/main/github/client/map/review-comment-response.ts new file mode 100644 index 00000000000..f13a69f3632 --- /dev/null +++ b/src/main/github/client/map/review-comment-response.ts @@ -0,0 +1,33 @@ +import type { PRComment } from '../../../../shared/github/comment-types' +export function mapReviewCommentResponse( + data: { + id?: number + node_id?: string | null + user: { login: string; avatar_url: string; type?: string } | null + body?: string + created_at?: string + html_url?: string + path?: string + line?: number | null + }, + body: string, + path?: string, + line?: number, + startLine?: number, + threadId?: string +): PRComment { + return { + id: data.id ?? Date.now(), + reactionSubjectId: data.node_id?.trim() || undefined, + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? body, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot', + path: data.path ?? path, + line: data.line ?? line, + startLine, + threadId + } +} diff --git a/src/main/github/client/map/work-item-field-coercion.ts b/src/main/github/client/map/work-item-field-coercion.ts new file mode 100644 index 00000000000..3f81041bc8a --- /dev/null +++ b/src/main/github/client/map/work-item-field-coercion.ts @@ -0,0 +1,213 @@ +import type { + PRMergeableState, + PRReviewDecision +} from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { summarizeProviderChecks } from '../../../../shared/provider-check-summary' +// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC. +export type MainWorkItem = Omit + +export const WORK_ITEM_PR_LIST_JSON_FIELDS = + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' + +// Requested reviewers stay in the list payload because Tasks renders that column on first paint. +// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL. +// Requested reviewers stay in the list payload because Tasks renders that column on first paint. +export 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' + +/** + * Derive author login + avatar_url together so GHE avatars render — the login-only + * `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784). + */ +export function authorFieldsFromUnknown( + item: Record +): Pick { + const user = userFromUnknown(item.user ?? item.author) + if (!user) { + return { author: null } + } + return { + author: user.login, + ...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {}) + } +} + +export function extractHeadOwnerLogin(item: Record): string | null { + // gh CLI `pr list --json headRepositoryOwner` shape: { login } + if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) { + const login = (item.headRepositoryOwner as { login?: unknown }).login + if (typeof login === 'string' && login.trim()) { + return login + } + } + // REST API `pull_request` shape: head.repo.owner.login + if (typeof item.head === 'object' && item.head !== null) { + const head = item.head as { repo?: unknown; user?: unknown; label?: unknown } + const repo = head.repo + if (typeof repo === 'object' && repo !== null) { + const owner = (repo as { owner?: unknown }).owner + if (typeof owner === 'object' && owner !== null) { + const login = (owner as { login?: unknown }).login + if (typeof login === 'string' && login.trim()) { + return login + } + } + } + // Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label. + const user = head.user + if (typeof user === 'object' && user !== null) { + const login = (user as { login?: unknown }).login + if (typeof login === 'string' && login.trim()) { + return login + } + } + if (typeof head.label === 'string') { + const owner = head.label.split(':', 1)[0]?.trim() + if (owner) { + return owner + } + } + } + return null +} + +export function userFromUnknown( + value: unknown +): { login: string; name: string | null; avatarUrl: string } | null { + if (typeof value === 'string') { + const login = value.trim() + return login ? { login, name: null, avatarUrl: '' } : null + } + if (typeof value !== 'object' || value === null) { + return null + } + const raw = value as Record + const login = typeof raw.login === 'string' ? raw.login.trim() : '' + if (!login) { + return null + } + const databaseId = numberFromUnknown(raw.databaseId) + return { + login, + name: typeof raw.name === 'string' ? raw.name : null, + avatarUrl: + typeof raw.avatarUrl === 'string' + ? raw.avatarUrl + : typeof raw.avatar_url === 'string' + ? raw.avatar_url + : databaseId !== undefined + ? `https://avatars.githubusercontent.com/u/${databaseId}?v=4` + : '' + } +} + +export function usersFromUnknown( + value: unknown +): { login: string; name: string | null; avatarUrl: string }[] { + if (!Array.isArray(value)) { + return [] + } + const users: { login: string; name: string | null; avatarUrl: string }[] = [] + for (const entry of value) { + const direct = userFromUnknown(entry) + if (direct) { + users.push(direct) + continue + } + if (typeof entry === 'object' && entry !== null) { + const raw = entry as Record + const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author) + if (nested) { + users.push(nested) + } + } + } + return users +} + +export function latestReviewsFromUnknown( + value: unknown +): NonNullable { + if (!Array.isArray(value)) { + return [] + } + const reviews: NonNullable = [] + for (const entry of value) { + if (typeof entry !== 'object' || entry === null) { + continue + } + const raw = entry as Record + const author = userFromUnknown(raw.author) + if (!author) { + continue + } + reviews.push({ + login: author.login, + state: typeof raw.state === 'string' ? raw.state : null, + avatarUrl: author.avatarUrl + }) + } + return reviews +} + +export function numberFromUnknown(value: unknown): number | undefined { + // Why: Number(null) is 0 — an explicit null must stay "unknown", not become a real count or user id. + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined + } + if (typeof value !== 'string' || !value.trim()) { + return undefined + } + const number = Number(value) + return Number.isFinite(number) ? number : undefined +} + +export 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 +} + +export function normalizeReviewDecision(value: unknown): PRReviewDecision | null { + return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED' + ? value + : null +} + +export function isAutoMergeEnabled(value: unknown): boolean { + return typeof value === 'object' && value !== null +} + +export function checkRollupEntries(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + if (typeof value !== 'object' || value === null) { + return [] + } + const raw = value as Record + const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes + return Array.isArray(nodes) ? nodes : [] +} + +export function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] { + return summarizeProviderChecks( + checkRollupEntries(value).map((entry) => { + if (typeof entry !== 'object' || entry === null) { + return { status: '', conclusion: '' } + } + const raw = entry as Record + // Why: StatusContext reports `state` and carries no `status`; CheckRun reports both. + return { + status: String(raw.status ?? ''), + conclusion: String(raw.conclusion ?? raw.state ?? '') + } + }) + ) +} diff --git a/src/main/github/client/map/work-item.ts b/src/main/github/client/map/work-item.ts new file mode 100644 index 00000000000..d127c1a6e67 --- /dev/null +++ b/src/main/github/client/map/work-item.ts @@ -0,0 +1,138 @@ +import type { OwnerRepo } from '../../gh-utils' +import { + authorFieldsFromUnknown, + extractHeadOwnerLogin, + usersFromUnknown, + latestReviewsFromUnknown, + numberFromUnknown, + normalizePRMergeable, + normalizeReviewDecision, + isAutoMergeEnabled, + deriveWorkItemCheckSummary, + type MainWorkItem +} from './work-item-field-coercion' +export function mapIssueWorkItem(item: Record): MainWorkItem { + return { + id: `issue:${String(item.number)}`, + type: 'issue', + number: Number(item.number), + title: String(item.title ?? ''), + state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open', + url: String(item.html_url ?? item.url ?? ''), + labels: Array.isArray(item.labels) + ? item.labels + .map((label) => + typeof label === 'object' && label !== null && 'name' in label + ? String((label as { name?: unknown }).name ?? '') + : '' + ) + .filter(Boolean) + : [], + updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), + ...authorFieldsFromUnknown(item), + ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}) + } +} + +export function mapPullRequestWorkItem( + item: Record, + baseOwnerRepo: OwnerRepo | null = null +): MainWorkItem { + // Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner. + const headOwnerLogin = extractHeadOwnerLogin(item) + // Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork". + const isCrossRepository = + headOwnerLogin !== null && baseOwnerRepo !== null + ? headOwnerLogin !== baseOwnerRepo.owner + : null + const state = String(item.state ?? '').toLowerCase() + const additions = numberFromUnknown(item.additions) + const deletions = numberFromUnknown(item.deletions) + const changedFiles = numberFromUnknown( + item.changedFiles ?? + item.changed_files ?? + (item.files as { totalCount?: unknown } | undefined)?.totalCount + ) + const mergeable = normalizePRMergeable(item.mergeable) + const headSha = + typeof item.headRefOid === 'string' + ? item.headRefOid + : typeof item.head === 'object' && item.head !== null + ? typeof (item.head as { sha?: unknown }).sha === 'string' + ? (item.head as { sha: string }).sha + : undefined + : undefined + return { + id: `pr:${String(item.number)}`, + type: 'pr', + number: Number(item.number), + title: String(item.title ?? ''), + state: + state === 'merged' || item.merged_at || item.mergedAt + ? 'merged' + : state === 'closed' + ? 'closed' + : item.isDraft || item.draft + ? 'draft' + : 'open', + url: String(item.html_url ?? item.url ?? ''), + labels: Array.isArray(item.labels) + ? item.labels + .map((label) => + typeof label === 'object' && label !== null && 'name' in label + ? String((label as { name?: unknown }).name ?? '') + : '' + ) + .filter(Boolean) + : [], + updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), + ...authorFieldsFromUnknown(item), + branchName: + typeof item.head === 'object' && item.head !== null && 'ref' in item.head + ? String((item.head as { ref?: unknown }).ref ?? '') + : String(item.headRefName ?? ''), + baseRefName: + typeof item.base === 'object' && item.base !== null && 'ref' in item.base + ? String((item.base as { ref?: unknown }).ref ?? '') + : String(item.baseRefName ?? ''), + ...(headSha ? { headSha } : {}), + ...(baseOwnerRepo + ? { + prRepo: { + owner: baseOwnerRepo.owner, + repo: baseOwnerRepo.repo, + ...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {}) + } + } + : {}), + ...(additions !== undefined ? { additions } : {}), + ...(deletions !== undefined ? { deletions } : {}), + ...(changedFiles !== undefined ? { changedFiles } : {}), + ...('reviewDecision' in item + ? { reviewDecision: normalizeReviewDecision(item.reviewDecision) } + : {}), + ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined + ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) } + : {}), + ...(item.latestReviews !== undefined + ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } + : {}), + ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}), + ...(item.statusCheckRollup !== undefined + ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) } + : {}), + ...(mergeable ? { mergeable } : {}), + ...('autoMergeRequest' in item + ? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) } + : {}), + ...('mergeStateStatus' in item + ? { + mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null + } + : {}), + ...(typeof item.maintainerCanModify === 'boolean' + ? { maintainerCanModify: item.maintainerCanModify } + : {}), + ...(isCrossRepository !== null ? { isCrossRepository } : {}) + } +} diff --git a/src/main/github/client/merge/merge-pr.ts b/src/main/github/client/merge/merge-pr.ts new file mode 100644 index 00000000000..07518d7993c --- /dev/null +++ b/src/main/github/client/merge/merge-pr.ts @@ -0,0 +1,172 @@ +import type { PRConflictSummary } from '../../../../shared/github/pull-request-types' +import { getPRConflictSummary } from '../../conflict-summary' +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { mergeGitHubPRStack } from '../../github-pr-stack' +import { githubPRStackExecutionScope, type GhExecOptions } from './../github-exec-scope' +import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import type { PullRequestLookupData } from './../lookup/pull-request-lookup-data' +import { getRestPRByNumber, getPRByNumber } from './../lookup/pr-number-lookup' +import { STACK_METADATA_UNAVAILABLE_ERROR } from './../lookup/pr-stack-summary-cache' +/** + * Merge a PR by number using gh CLI. + * method: 'merge' | 'squash' | 'rebase' (default: 'squash') + */ +export async function mergePR( + repoPath: string, + prNumber: number, + method: 'merge' | 'squash' | 'rebase' = 'squash', + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + await acquire() + let concurrencySlotHeld = true + try { + let restData: PullRequestLookupData + try { + restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions, { + requireUsableStackMetadata: true + }) + } catch (err) { + const diagnostic = + err instanceof SyntaxError + ? 'invalid JSON response' + : err instanceof Error + ? err.message + : String(err) + console.warn( + `mergePR stack metadata probe failed for ${ownerRepo.owner}/${ownerRepo.repo}#${String(prNumber)}:`, + diagnostic + ) + return { ok: false, error: STACK_METADATA_UNAVAILABLE_ERROR } + } + if (restData.stack) { + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + restData.stack.baseRefName, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + release() + concurrencySlotHeld = false + return await mergeGitHubPRStack({ + repository: ownerRepo, + prNumber, + method, + mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', + headSha: restData.headRefOid, + ghOptions + }) + } + const mergeBlocker = await getPRMergeBlocker( + repoPath, + prNumber, + ownerRepo, + ghOptions, + connectionId, + localGitOptions + ) + if (mergeBlocker) { + return { ok: false, error: mergeBlocker } + } + + // Don't use --delete-branch: it deletes the local branch, which fails while the worktree is checked out on it. + const args = ['pr', 'merge', String(prNumber), `--${method}`] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: message } + } finally { + if (concurrencySlotHeld) { + release() + } + } +} + +export async function getPRMergeBlocker( + repoPath: string, + prNumber: number, + ownerRepo: GitHubApiRepository | null, + ghOptions: GhExecOptions, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!ownerRepo) { + return null + } + + try { + const pr = await getPRByNumber( + ownerRepo, + prNumber, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + if (!pr) { + return null + } + if (pr.reviewDecision === 'REVIEW_REQUIRED') { + return 'This pull request requires review approval before it can be merged.' + } + if (pr.reviewDecision === 'CHANGES_REQUESTED') { + return 'This pull request has requested changes and cannot be merged yet.' + } + if (pr.mergeQueueRequired === true) { + return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + } + // Why: conflict summaries shell out to local git; skip for SSH repos until that helper routes through the SSH provider. + if ( + connectionId || + pr.mergeable !== 'CONFLICTING' || + !pr.baseRefName || + !pr.baseRefOid || + !pr.headRefOid + ) { + return null + } + + const summary = await getPRConflictSummary( + repoPath, + pr.baseRefName, + pr.baseRefOid, + pr.headRefOid, + localGitOptions + ) + return formatMergeConflictBlocker(pr.baseRefName, summary) + } catch { + // Why: conflict preflight should improve stale UI diagnostics, not block merge on a transient lookup failure. + return null + } +} + +export 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}` +} diff --git a/src/main/github/client/merge/pr-auto-merge.ts b/src/main/github/client/merge/pr-auto-merge.ts new file mode 100644 index 00000000000..0cc65c5fb03 --- /dev/null +++ b/src/main/github/client/merge/pr-auto-merge.ts @@ -0,0 +1,192 @@ +import type { GitHubPRMergeMethod } from '../../../../shared/github/pull-request-types' +import { + ghExecFileAsync, + acquire, + release, + classifyGhError, + type LocalGitExecOptions +} from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { githubPRStackExecutionScope, type GhExecOptions } from './../github-exec-scope' +import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import { getRestPRByNumber } from './../lookup/pr-number-lookup' +export const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName' + +export const GITHUB_AUTO_MERGE_METHODS: Record = + { + merge: 'MERGE', + squash: 'SQUASH', + rebase: 'REBASE' + } + +// Why: GitHub rejects auto-merge on an already-mergeable PR ("clean status"); surface an actionable message instead of the raw error. +export 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 +} + +export type PRAutoMergeIdentity = { + id?: string + headRefOid?: string + baseRefName?: string +} + +export async function getPRAutoMergeIdentity( + prNumber: number, + ownerRepo: GitHubApiRepository | null, + ghOptions: GhExecOptions +): Promise { + const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + const { stdout } = await ghExecFileAsync(args, ghOptions) + const data = JSON.parse(stdout) as PRAutoMergeIdentity + return { + id: typeof data.id === 'string' ? data.id : undefined, + headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined, + baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined + } +} + +export async function runPRAutoMergeCommand( + prNumber: number, + method: GitHubPRMergeMethod, + ownerRepo: GitHubApiRepository | null, + ghOptions: GhExecOptions +): Promise { + const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) +} + +export async function shouldUseMergeQueueAutoMerge( + pr: PRAutoMergeIdentity, + ownerRepo: GitHubApiRepository | null, + ghOptions: GhExecOptions, + executionScope?: string +): Promise { + if (!ownerRepo || !pr.baseRefName) { + return false + } + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + pr.baseRefName, + ghOptions, + executionScope + ) + return mergeMetadata.mergeQueueRequired === true +} + +export async function enablePRAutoMerge( + prNumber: number, + method: GitHubPRMergeMethod, + ownerRepo: GitHubApiRepository | null, + ghOptions: GhExecOptions, + executionScope?: string +): Promise<{ ok: true } | { ok: false; error: string }> { + if (ownerRepo) { + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + if (restData.stack) { + return { + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + } + } + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + } + const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) + if (!pr?.id) { + return { ok: false, error: 'Could not resolve GitHub pull request ID' } + } + const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions, executionScope) + if (useMergeQueue) { + await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) + return { ok: true } + } + const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) { + enablePullRequestAutoMerge(input: { + pullRequestId: $pullRequestId, + mergeMethod: $mergeMethod, + expectedHeadOid: $expectedHeadOid + }) { + pullRequest { id } + } + }` + const args = [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `pullRequestId=${pr.id}`, + '-f', + `mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}` + ] + if (pr.headRefOid) { + args.push('-f', `expectedHeadOid=${pr.headRefOid}`) + } + // Why: `gh pr merge --auto` can merge immediately; this mutation only creates the auto-merge request, letting branch requirements gate it. + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } +} + +export async function setPRAutoMerge( + repoPath: string, + prNumber: number, + enabled: boolean, + method: GitHubPRMergeMethod = 'squash', + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + await acquire() + try { + if (enabled) { + return await enablePRAutoMerge( + prNumber, + method, + ownerRepo, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + } + 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() + } +} diff --git a/src/main/github/client/pull-request-lookup-candidates.ts b/src/main/github/client/pull-request-lookup-candidates.ts new file mode 100644 index 00000000000..4fedbd3d42e --- /dev/null +++ b/src/main/github/client/pull-request-lookup-candidates.ts @@ -0,0 +1,21 @@ +import type { IssueSourcePreference } from '../../../shared/repo-types' +import type { LocalGitExecOptions } from '../gh-utils' +import { + getOriginGitHubApiRepository, + resolveGitHubApiRepositoryCandidates, + type GitHubApiRepository +} from '../github-api-repository' +// resolvePrWorkItemSource list semantics. +export async function resolvePullRequestLookupCandidates( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'origin') { + const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions) + return origin ? [origin] : [] + } + return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions)) + .candidates +} diff --git a/src/main/github/client/update/pr-comment-reaction.ts b/src/main/github/client/update/pr-comment-reaction.ts new file mode 100644 index 00000000000..872ba463eb9 --- /dev/null +++ b/src/main/github/client/update/pr-comment-reaction.ts @@ -0,0 +1,60 @@ +import type { GitHubReactionContent } from '../../../../shared/github/comment-types' +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { toGraphQLReactionContent } from '../../comment-reactions' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +export async function setPRCommentReaction( + repoPath: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const mutation = reacted ? 'addReaction' : 'removeReaction' + const query = `mutation($subjectId: ID!, $content: ReactionContent!) { + ${mutation}(input: { subjectId: $subjectId, content: $content }) { + subject { id } + } + }` + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + console.warn( + `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` + ) + return false + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `subjectId=${reactionSubjectId}`, + '-f', + `content=${toGraphQLReactionContent(content)}` + ], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} diff --git a/src/main/github/client/update/pr-details.ts b/src/main/github/client/update/pr-details.ts new file mode 100644 index 00000000000..2348174e931 --- /dev/null +++ b/src/main/github/client/update/pr-details.ts @@ -0,0 +1,100 @@ +import { + ghExecFileAsync, + acquire, + release, + classifyPullRequestUpdateError, + type LocalGitExecOptions +} from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +/** + * Update a PR's title. + */ +export async function updatePRTitle( + repoPath: string, + prNumber: number, + title: string, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + await acquire() + try { + const args = ['pr', 'edit', String(prNumber), '--title', title] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions + }) + return true + } catch (err) { + console.warn('updatePRTitle failed:', err) + return false + } finally { + release() + } +} + +export async function updatePRDetails( + repoPath: string, + prNumber: number, + updates: { title?: string; body?: string }, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + + const fields: string[] = [] + if (updates.title !== undefined) { + const title = updates.title.trim() + if (!title) { + return { ok: false, error: 'Title is required' } + } + fields.push(`title=${title}`) + } + if (updates.body !== undefined) { + fields.push(`body=${updates.body}`) + } + if (fields.length === 0) { + return { ok: true } + } + + await acquire() + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`, + ...fields.flatMap((field) => ['--raw-field', field]) + ], + ghOptions + ) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyPullRequestUpdateError(message).message } + } finally { + release() + } +} diff --git a/src/main/github/client/update/pr-file-viewed.ts b/src/main/github/client/update/pr-file-viewed.ts new file mode 100644 index 00000000000..018de3c328b --- /dev/null +++ b/src/main/github/client/update/pr-file-viewed.ts @@ -0,0 +1,62 @@ +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +/** + * Mark or unmark a PR file as viewed via GitHub's GraphQL API. + */ +export async function setPRFileViewed(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + pullRequestId: string + path: string + viewed: boolean +}): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions + ) + if (!ownerRepo) { + return false + } + const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed' + const query = `mutation($pullRequestId: ID!, $path: String!) { + ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) { + pullRequest { id } + } + }` + // Why: viewed toggles fire once per file while reading a diff — the highest-volume GraphQL caller here. + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + console.warn( + `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` + ) + return false + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `pullRequestId=${args.pullRequestId}`, + '-f', + `path=${args.path}` + ], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} diff --git a/src/main/github/client/update/pr-reviewers.ts b/src/main/github/client/update/pr-reviewers.ts new file mode 100644 index 00000000000..e502158f661 --- /dev/null +++ b/src/main/github/client/update/pr-reviewers.ts @@ -0,0 +1,83 @@ +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +export async function requestPRReviewers( + repoPath: string, + prNumber: number, + reviewers: string[], + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) + if (logins.length === 0) { + return { ok: false, error: 'Enter at least one reviewer' } + } + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + await acquire() + try { + const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: message } + } finally { + release() + } +} + +export async function removePRReviewers( + repoPath: string, + prNumber: number, + reviewers: string[], + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) + if (logins.length === 0) { + return { ok: false, error: 'Enter at least one reviewer' } + } + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + await acquire() + try { + const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/github/client/update/pr-state.ts b/src/main/github/client/update/pr-state.ts new file mode 100644 index 00000000000..d22916c8d54 --- /dev/null +++ b/src/main/github/client/update/pr-state.ts @@ -0,0 +1,46 @@ +import type { GitHubPullRequestStateUpdate } from '../../../../shared/issue-mutation-types' +import { + ghExecFileAsync, + acquire, + release, + classifyPullRequestUpdateError, + type LocalGitExecOptions +} from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +export async function updatePRState( + repoPath: string, + prNumber: number, + updates: GitHubPullRequestStateUpdate, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + + await acquire() + try { + const cmd = updates.state === 'closed' ? 'close' : 'reopen' + // Why: gh's PR commands use GitHub's supported reopen flow; REST state PATCH can 422 on reopen. + await ghExecFileAsync( + ['pr', cmd, String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`], + { + ...ghOptions + } + ) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyPullRequestUpdateError(message).message } + } finally { + release() + } +} diff --git a/src/main/github/client/update/resolve-review-thread.ts b/src/main/github/client/update/resolve-review-thread.ts new file mode 100644 index 00000000000..2bad5d69b9c --- /dev/null +++ b/src/main/github/client/update/resolve-review-thread.ts @@ -0,0 +1,47 @@ +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +/** + * Resolve or unresolve a PR review thread via GraphQL. + */ +export async function resolveReviewThread( + repoPath: string, + threadId: string, + resolve: boolean, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread' + const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }` + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + console.warn( + `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` + ) + return false + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + await ghExecFileAsync( + ['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts index 551365f2c9c..bb333bf06d0 100644 --- a/src/main/github/gh-error-classification.ts +++ b/src/main/github/gh-error-classification.ts @@ -56,6 +56,24 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { return { type: c.type, message: readMessages[c.type] } } +// Why: classifyGhError's copy names an "issue"; PR mutations reuse the same +// classification but must not tell the user their pull request is an issue. +export function classifyPullRequestUpdateError(stderr: string): ClassifiedError { + const c = classifyGhError(stderr) + const trimmed = stderr.trim() + const pullRequestMessages: Record = { + permission_denied: + "You don't have permission to edit this pull request. Check your GitHub token scopes.", + not_found: 'Pull request not found — it may have been deleted.', + issues_disabled: c.message, + validation_error: `Invalid update — ${trimmed}`, + rate_limited: c.message, + network_error: c.message, + unknown: `Failed to update pull request: ${trimmed}` + } + return { type: c.type, message: pullRequestMessages[c.type] } +} + // Why: PR-side list failures need the same read-op classification — pagination // decisions key on the type, and swallowing them made failures look like // end-of-data (#11485). @@ -63,3 +81,21 @@ export function classifyListPrsError(stderr: string): ClassifiedError { const c = classifyGhError(stderr) return { type: c.type, message: `Failed to load pull requests: ${stderr.trim()}` } } + +// Why: classifyGhError's copy names an "issue"; a failed check rerun must name +// the operation the user actually triggered. +export function classifyRerunChecksError(stderr: string): ClassifiedError { + const c = classifyGhError(stderr) + const trimmed = stderr.trim() + const rerunMessages: Record = { + permission_denied: + "You don't have permission to rerun checks on this repository. Check your GitHub token scopes.", + not_found: 'GitHub resource to rerun was not found — it may have expired or been deleted.', + issues_disabled: `Failed to rerun checks: ${trimmed}`, + validation_error: `Could not rerun checks — ${trimmed}`, + rate_limited: c.message, + network_error: c.message, + unknown: `Failed to rerun checks: ${trimmed}` + } + return { type: c.type, message: rerunMessages[c.type] } +} diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 7f4c4e05830..61b3c2d877d 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -12,7 +12,8 @@ export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs export { classifyGhError, classifyListIssuesError, - classifyListPrsError + classifyListPrsError, + classifyPullRequestUpdateError } from './gh-error-classification' export { _getOwnerRepoCacheSize, diff --git a/src/main/updater.linux-root-package-install.test.ts b/src/main/updater.linux-root-package-install.test.ts index 3cb6e03b32d..13534866863 100644 --- a/src/main/updater.linux-root-package-install.test.ts +++ b/src/main/updater.linux-root-package-install.test.ts @@ -115,7 +115,9 @@ describe('updater', () => { const holdRevalidation = (): { settle: (verdict: RevalidationVerdict) => void fail: (error: Error) => void + invocationCount: () => number } => { + let invocationCount = 0 let pending: { resolve: (verdict: RevalidationVerdict) => void reject: (error: Error) => void @@ -126,12 +128,12 @@ describe('updater', () => { ) return { ...actual, - revalidateLinuxPackageForInstall: vi.fn( - () => - new Promise((resolve, reject) => { - pending = { resolve, reject } - }) - ) + revalidateLinuxPackageForInstall: vi.fn(() => { + invocationCount += 1 + return new Promise((resolve, reject) => { + pending = { resolve, reject } + }) + }) } }) return { @@ -142,7 +144,8 @@ describe('updater', () => { fail: (error) => { pending?.reject(error) pending = null - } + }, + invocationCount: () => invocationCount } } @@ -589,13 +592,19 @@ describe('updater', () => { // Why: a second click during the multi-second hash must not schedule a parallel install. it('ignores a second install request while the digest re-proof runs', async () => { + const revalidation = holdRevalidation() const { updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) updater.quitAndInstall() - // Fires the quit timer, which starts the hash; the read itself is still outstanding. + // Fires the quit timer, which starts the re-proof; its verdict is still outstanding. await vi.advanceTimersByTimeAsync(100) + expect(revalidation.invocationCount()).toBe(1) updater.quitAndInstall() + // Advancing here proves the second request never scheduled its own quit timer. + await vi.advanceTimersByTimeAsync(100) + expect(revalidation.invocationCount()).toBe(1) + revalidation.settle({ ok: true }) await settleQuitAndInstall() expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)