From 63dbf12d147b942ff9e6984404dcdd92563e24c0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:18:44 -0700 Subject: [PATCH] Split github client (#15214) * refactor(github-client): reorganize client into lifecycle folders * refactor(github-client): extract PR refresh data and outcome assembly Separate the derived data calculation and outcome assembly logic from branch-lookup-resolution into dedicated modules for better separation of concerns. Modernize type import syntax and format exports consistently. * refactor(github-client): improve error handling and resilience Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks. Cache failures now use shorter TTLs for faster recovery. PR operations have dedicated error classification. GraphQL mutations track rate limit usage to prevent quota exhaustion. Data validation improved to reject spurious values. * Extract check rerun error classification with operation context Create classifyRerunChecksError() to provide operation-specific error messages when check reruns fail. This replaces generic GitHub error copy with context appropriate to what the user attempted (rerun checks). Follows the pattern of classifyListPrsError and improves error handling by delegating extraction to extractExecError. * Make check-rerun not-found error message resource-neutral Error handling for failed check reruns now covers both workflow-run reruns and standalone check-run rerequests. Tests verify the neutral message works for both scenarios. --- config/max-lines-baseline.txt | 1 - src/main/github/client-pr-checks.test.ts | 68 + src/main/github/client.ts | 5629 +---------------- .../check/check-detail-field-mapping.ts | 83 + .../client/check/check-details-abort.ts | 26 + .../client/check/check-job-log-tails.ts | 85 + .../client/check/get-pr-check-details.ts | 128 + src/main/github/client/check/get-pr-checks.ts | 233 + .../client/check/pr-checks-graphql-query.ts | 133 + .../check/pr-checks-response-mapping.ts | 186 + .../github/client/check/rerun-pr-checks.ts | 105 + .../client/create/add-pr-review-comment.ts | 121 + .../create/create-github-pull-request.ts | 162 + .../create/create-pr-error-classification.ts | 74 + .../client/create/pull-request-template.ts | 83 + .../hydrate-work-item-merge-metadata.ts | 38 + .../detect/repository-merge-metadata-cache.ts | 56 + .../detect/repository-merge-metadata.ts | 121 + .../client/fetch/authenticated-viewer.ts | 28 + .../github/client/fetch/get-pr-comments.ts | 279 + src/main/github/client/fetch/get-work-item.ts | 144 + src/main/github/client/fetch/orca-star.ts | 49 + .../client/fetch/pr-review-threads-query.ts | 70 + .../github/client/fetch/repo-slug-upstream.ts | 84 + .../github/client/fetch/work-item-fetch.ts | 199 + src/main/github/client/gh-error-predicates.ts | 43 + src/main/github/client/github-exec-scope.ts | 35 + .../github/client/list/count-work-items.ts | 154 + .../github/client/list/list-work-items.ts | 85 + .../client/list/work-item-list-request.ts | 146 + .../github/client/list/work-item-pages.ts | 241 + .../client/list/work-item-search-query.ts | 66 + .../lookup/branch-lookup-derived-data.ts | 76 + .../client/lookup/branch-lookup-resolution.ts | 294 + .../github/client/lookup/get-pr-for-branch.ts | 32 + .../github/client/lookup/pr-branch-lookup.ts | 163 + .../client/lookup/pr-for-branch-outcome.ts | 44 + .../client/lookup/pr-lookup-rate-limit.ts | 70 + .../github/client/lookup/pr-number-lookup.ts | 152 + .../lookup/pr-refresh-outcome-assembly.ts | 70 + .../client/lookup/pr-stack-summary-cache.ts | 92 + .../client/lookup/pull-request-lookup-data.ts | 180 + .../lookup/pull-request-lookup-hydration.ts | 33 + .../client/lookup/pull-request-push-target.ts | 154 + .../lookup/rest-stack-metadata-validation.ts | 32 + .../client/lookup/tracked-upstream-branch.ts | 191 + .../client/lookup/tracked-upstream-cache.ts | 146 + .../client/map/review-comment-response.ts | 33 + .../client/map/work-item-field-coercion.ts | 213 + src/main/github/client/map/work-item.ts | 138 + src/main/github/client/merge/merge-pr.ts | 172 + src/main/github/client/merge/pr-auto-merge.ts | 192 + .../client/pull-request-lookup-candidates.ts | 21 + .../client/update/pr-comment-reaction.ts | 60 + src/main/github/client/update/pr-details.ts | 100 + .../github/client/update/pr-file-viewed.ts | 62 + src/main/github/client/update/pr-reviewers.ts | 83 + src/main/github/client/update/pr-state.ts | 46 + .../client/update/resolve-review-thread.ts | 47 + src/main/github/gh-error-classification.ts | 36 + src/main/github/gh-utils.ts | 3 +- ...updater.linux-root-package-install.test.ts | 25 +- 62 files changed, 6314 insertions(+), 5601 deletions(-) create mode 100644 src/main/github/client/check/check-detail-field-mapping.ts create mode 100644 src/main/github/client/check/check-details-abort.ts create mode 100644 src/main/github/client/check/check-job-log-tails.ts create mode 100644 src/main/github/client/check/get-pr-check-details.ts create mode 100644 src/main/github/client/check/get-pr-checks.ts create mode 100644 src/main/github/client/check/pr-checks-graphql-query.ts create mode 100644 src/main/github/client/check/pr-checks-response-mapping.ts create mode 100644 src/main/github/client/check/rerun-pr-checks.ts create mode 100644 src/main/github/client/create/add-pr-review-comment.ts create mode 100644 src/main/github/client/create/create-github-pull-request.ts create mode 100644 src/main/github/client/create/create-pr-error-classification.ts create mode 100644 src/main/github/client/create/pull-request-template.ts create mode 100644 src/main/github/client/detect/hydrate-work-item-merge-metadata.ts create mode 100644 src/main/github/client/detect/repository-merge-metadata-cache.ts create mode 100644 src/main/github/client/detect/repository-merge-metadata.ts create mode 100644 src/main/github/client/fetch/authenticated-viewer.ts create mode 100644 src/main/github/client/fetch/get-pr-comments.ts create mode 100644 src/main/github/client/fetch/get-work-item.ts create mode 100644 src/main/github/client/fetch/orca-star.ts create mode 100644 src/main/github/client/fetch/pr-review-threads-query.ts create mode 100644 src/main/github/client/fetch/repo-slug-upstream.ts create mode 100644 src/main/github/client/fetch/work-item-fetch.ts create mode 100644 src/main/github/client/gh-error-predicates.ts create mode 100644 src/main/github/client/github-exec-scope.ts create mode 100644 src/main/github/client/list/count-work-items.ts create mode 100644 src/main/github/client/list/list-work-items.ts create mode 100644 src/main/github/client/list/work-item-list-request.ts create mode 100644 src/main/github/client/list/work-item-pages.ts create mode 100644 src/main/github/client/list/work-item-search-query.ts create mode 100644 src/main/github/client/lookup/branch-lookup-derived-data.ts create mode 100644 src/main/github/client/lookup/branch-lookup-resolution.ts create mode 100644 src/main/github/client/lookup/get-pr-for-branch.ts create mode 100644 src/main/github/client/lookup/pr-branch-lookup.ts create mode 100644 src/main/github/client/lookup/pr-for-branch-outcome.ts create mode 100644 src/main/github/client/lookup/pr-lookup-rate-limit.ts create mode 100644 src/main/github/client/lookup/pr-number-lookup.ts create mode 100644 src/main/github/client/lookup/pr-refresh-outcome-assembly.ts create mode 100644 src/main/github/client/lookup/pr-stack-summary-cache.ts create mode 100644 src/main/github/client/lookup/pull-request-lookup-data.ts create mode 100644 src/main/github/client/lookup/pull-request-lookup-hydration.ts create mode 100644 src/main/github/client/lookup/pull-request-push-target.ts create mode 100644 src/main/github/client/lookup/rest-stack-metadata-validation.ts create mode 100644 src/main/github/client/lookup/tracked-upstream-branch.ts create mode 100644 src/main/github/client/lookup/tracked-upstream-cache.ts create mode 100644 src/main/github/client/map/review-comment-response.ts create mode 100644 src/main/github/client/map/work-item-field-coercion.ts create mode 100644 src/main/github/client/map/work-item.ts create mode 100644 src/main/github/client/merge/merge-pr.ts create mode 100644 src/main/github/client/merge/pr-auto-merge.ts create mode 100644 src/main/github/client/pull-request-lookup-candidates.ts create mode 100644 src/main/github/client/update/pr-comment-reaction.ts create mode 100644 src/main/github/client/update/pr-details.ts create mode 100644 src/main/github/client/update/pr-file-viewed.ts create mode 100644 src/main/github/client/update/pr-reviewers.ts create mode 100644 src/main/github/client/update/pr-state.ts create mode 100644 src/main/github/client/update/resolve-review-thread.ts 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)