diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 191a47038f2..a9d24ae637a 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -77,6 +77,15 @@ import { findRepoForGitHubProjectRepository, type GitHubRepoSlugCacheEntry } from '../../../src/tasks/github-project-repo-match' +import { + parseGitHubProjectInput as parseProjectInput, + type GitHubProjectOwnerType, + type GitHubProjectPartialFailure, + type GitHubProjectRef, + type GitHubProjectSettings, + type GitHubProjectSummary, + type GitHubProjectViewSummary +} from '../../../src/tasks/github-project-reference' import { extractGitHubIssueSourceFallback, extractGitHubIssueSourceError, @@ -130,11 +139,16 @@ import { } from '../../../src/tasks/mobile-task-copy-feedback-timer' import type { BaseRefSearchResult, + GitHubOwnerRepo, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent } from '../../../../src/shared/types' import type { SshConnectionState } from '../../../../src/shared/ssh-types' +import { + githubProjectHost, + githubProjectIdentityKey as githubProjectKey +} from '../../../../src/shared/github-project-identity' type RepoSummary = { id: string @@ -148,11 +162,6 @@ type RepoSummary = { type IssueSourcePreference = 'upstream' | 'origin' | 'auto' -type GitHubOwnerRepo = { - owner: string - repo: string -} - type GitHubWorkItem = { id: string type: 'issue' | 'pr' @@ -444,34 +453,6 @@ type LinearStatusResponse = { activeWorkspaceId?: string | null } -type GitHubProjectOwnerType = 'organization' | 'user' -type GitHubProjectRef = { - owner: string - ownerType: GitHubProjectOwnerType - number: number -} -type GitHubProjectSettings = { - pinned: GitHubProjectRef[] - recent: Array - lastViewByProject: Record - activeProject: GitHubProjectRef | null -} -type GitHubProjectSummary = GitHubProjectRef & { - id: string - title: string - url: string - source: string -} -type GitHubProjectPartialFailure = { - owner: string - message: string -} -type GitHubProjectViewSummary = { - id: string - number: number - name: string - layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' -} type GitHubIssueType = { id: string name: string @@ -951,48 +932,6 @@ function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHu : 'issues' } -function githubProjectKey(project: GitHubProjectRef): string { - return `${project.ownerType}:${project.owner}:${project.number}` -} - -function parseProjectInput( - input: string -): { owner: string; number: number; viewNumber?: number } | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) - if (short) { - return { owner: short[1]!, number: Number(short[2]) } - } - try { - const url = new URL(trimmed) - if (url.hostname !== 'github.com') { - return null - } - const parts = url.pathname.split('/').filter(Boolean) - if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) { - const number = Number(parts[3]) - if (!Number.isInteger(number) || number < 1) { - return null - } - const viewNumber = - parts[4] === 'views' && parts[5] && Number.isInteger(Number(parts[5])) - ? Number(parts[5]) - : undefined - return { - owner: parts[1]!, - number, - ...(viewNumber && viewNumber > 0 ? { viewNumber } : {}) - } - } - } catch { - return null - } - return null -} - function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null { if (row.itemType === 'ISSUE') { return 'issue' @@ -1014,6 +953,11 @@ function splitRepositorySlug(slug: string | null): { owner: string; repo: string return owner && repo ? { owner, repo } : null } +function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null { + const slug = splitRepositorySlug(row.content.repository) + return slug ? { ...slug, host } : null +} + const GITHUB_PROJECT_OPTION_COLORS: Record = { GRAY: '#8b949e', RED: '#f85149', @@ -2468,6 +2412,10 @@ export default function MobileTasksScreen() { tasksSupportState.kind === 'unsupported' && tasksSupportState.client === client const taskUiReady = tasksSupported && taskStateHydrated + const activeGitHubProject = githubProjectSettings.activeProject + const activeGitHubProjectHost = githubProjectHost( + githubProjectTable?.project.host ?? activeGitHubProject?.host + ) const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos]) const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos]) const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) @@ -2483,9 +2431,10 @@ export default function MobileTasksScreen() { findRepoForGitHubProjectRepository( row.content.repository, hostedRepos, - githubRepoSlugCache + githubRepoSlugCache, + activeGitHubProjectHost ) as RepoSummary | null, - [githubRepoSlugCache, hostedRepos] + [activeGitHubProjectHost, githubRepoSlugCache, hostedRepos] ) const githubProjectRepoSlugReady = useMemo( () => @@ -2501,10 +2450,11 @@ export default function MobileTasksScreen() { ? (filterGitHubProjectRowsForRepos( githubProjectTable.rows, hostedRepos, - githubRepoSlugCache + githubRepoSlugCache, + activeGitHubProjectHost ) as GitHubProjectRow[]) : [], - [githubProjectTable, githubRepoSlugCache, hostedRepos] + [activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos] ) const visibleGitHubProjectGroups = useMemo(() => { if (!githubProjectTable) { @@ -2599,10 +2549,10 @@ export default function MobileTasksScreen() { return { repoId: repo.id, path: repo.path, - slug: result ? `${result.owner}/${result.repo}` : null + repository: result } } catch { - return { repoId: repo.id, path: repo.path, slug: null } + return { repoId: repo.id, path: repo.path, repository: null } } }).then((entries) => { if (cancelled) { @@ -2611,7 +2561,7 @@ export default function MobileTasksScreen() { setGithubRepoSlugCache((current) => { const next = { ...current } for (const entry of entries) { - next[entry.repoId] = { path: entry.path, slug: entry.slug } + next[entry.repoId] = { path: entry.path, repository: entry.repository } } return next }) @@ -2630,7 +2580,6 @@ export default function MobileTasksScreen() { taskStateHydrated, tasksSupported ]) - const activeGitHubProject = githubProjectSettings.activeProject const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null const activeGitHubProjectViewId = activeGitHubProjectKey ? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId @@ -3738,7 +3687,9 @@ export default function MobileTasksScreen() { } setGithubProjectError('') setGithubProjectPartialFailures([]) - const response = await client.sendRequest('github.project.listAccessible', {}) + const response = await client.sendRequest('github.project.listAccessible', { + host: 'github.com' + }) if (!isSuccess(response)) { throw new Error(response.error.message) } @@ -3763,6 +3714,7 @@ export default function MobileTasksScreen() { } const response = await client.sendRequest('github.project.listViews', { owner: project.owner, + host: githubProjectHost(project.host), ownerType: project.ownerType, projectNumber: project.number }) @@ -3800,6 +3752,7 @@ export default function MobileTasksScreen() { 'github.project.viewTable', { owner: activeGitHubProject.owner, + host: activeGitHubProjectHost, ownerType: activeGitHubProject.ownerType, projectNumber: activeGitHubProject.number, viewId: activeGitHubProjectViewId, @@ -3838,7 +3791,14 @@ export default function MobileTasksScreen() { setGithubProjectLoading(false) } }, - [activeGitHubProject, activeGitHubProjectViewId, client, connState, tasksSupported] + [ + activeGitHubProject, + activeGitHubProjectHost, + activeGitHubProjectViewId, + client, + connState, + tasksSupported + ] ) const commitGitHubProjectView = useCallback( @@ -3932,7 +3892,8 @@ export default function MobileTasksScreen() { return } const input = githubProjectPasteInput.trim() - if (!parseProjectInput(input)) { + const parsed = parseProjectInput(input) + if (!parsed) { setGithubProjectPasteError('Expected a project URL or owner/number.') return } @@ -3940,7 +3901,10 @@ export default function MobileTasksScreen() { setGithubProjectPasteError('') setGithubProjectError('') try { - const response = await client.sendRequest('github.project.resolveRef', { input }) + const response = await client.sendRequest('github.project.resolveRef', { + input, + host: githubProjectHost(parsed.host) + }) if (!isSuccess(response)) { throw new Error(response.error.message) } @@ -3951,6 +3915,7 @@ export default function MobileTasksScreen() { ownerType: GitHubProjectOwnerType number: number title: string + host?: string viewNumber?: number } | { ok: false; error: { message: string } } @@ -3964,7 +3929,8 @@ export default function MobileTasksScreen() { { owner: result.owner, ownerType: result.ownerType, - number: result.number + number: result.number, + host: githubProjectHost(result.host ?? parsed.host) }, { viewNumber: result.viewNumber } ) @@ -4536,6 +4502,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: projectRowItem.content.number, type }, @@ -4610,7 +4577,14 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, githubProjectTable, projectRowDetailRefreshSeq, projectRowItem, tasksSupported]) + }, [ + activeGitHubProjectHost, + client, + githubProjectTable, + projectRowDetailRefreshSeq, + projectRowItem, + tasksSupported + ]) useEffect(() => { const slug = splitRepositorySlug(projectMetadataRepository) @@ -4628,7 +4602,7 @@ export default function MobileTasksScreen() { void client .sendRequest( 'github.project.listLabelsBySlug', - { owner: slug.owner, repo: slug.repo }, + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) .then((response) => { @@ -4660,7 +4634,7 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectMetadataRepository, tasksSupported]) + }, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported]) useEffect(() => { const slug = splitRepositorySlug(projectMetadataRepository) @@ -4681,6 +4655,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, ...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {}) }, { timeoutMs: 30_000 } @@ -4716,7 +4691,13 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectMetadataRepository, projectMetadataSeedLogins, tasksSupported]) + }, [ + activeGitHubProjectHost, + client, + projectMetadataRepository, + projectMetadataSeedLogins, + tasksSupported + ]) useEffect(() => { const slug = splitRepositorySlug(projectIssueTypeRepository) @@ -4734,7 +4715,7 @@ export default function MobileTasksScreen() { void client .sendRequest( 'github.project.listIssueTypesBySlug', - { owner: slug.owner, repo: slug.repo }, + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) .then((response) => { @@ -4768,7 +4749,7 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectIssueTypeRepository, tasksSupported]) + }, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported]) const getWorkspaceTargetRepo = useCallback( (item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => { @@ -5729,6 +5710,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, updates }, @@ -5790,7 +5772,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const addProjectRowComment = useCallback( @@ -5810,6 +5792,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, body }, @@ -5838,7 +5821,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectCommentDraft, projectMutating] + [activeGitHubProjectHost, client, projectCommentDraft, projectMutating] ) const updateProjectRowComment = useCallback( @@ -5861,6 +5844,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, commentId, body }, @@ -5898,7 +5882,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectEditingCommentDraft, projectMutating] + [activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating] ) const deleteProjectRowComment = useCallback( @@ -5920,6 +5904,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, commentId }, { timeoutMs: 30_000 } @@ -5956,7 +5941,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectEditingCommentId, projectMutating] + [activeGitHubProjectHost, client, projectEditingCommentId, projectMutating] ) const toggleProjectGitHubReviewThread = useCallback( @@ -5979,6 +5964,7 @@ export default function MobileTasksScreen() { 'github.resolveReviewThread', { repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), threadId: comment.threadId, resolve }, @@ -6010,7 +5996,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] ) const replyToProjectGitHubComment = useCallback( @@ -6038,6 +6024,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), commentId: comment.id, body, threadId: comment.threadId, @@ -6051,6 +6038,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, number: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), body: `@${commentAuthor(comment)} ${body}`, type: projectRowType(row) ?? 'issue' }, @@ -6092,7 +6080,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, itemReplyDrafts, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating] ) const mutateProjectRowMetadata = useCallback( @@ -6120,6 +6108,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, updates }, @@ -6204,7 +6193,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const mutateProjectRowField = useCallback( @@ -6223,11 +6212,13 @@ export default function MobileTasksScreen() { value === null ? { projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, itemId: row.id, fieldId: field.id } : { projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, itemId: row.id, fieldId: field.id, value @@ -6274,7 +6265,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, githubProjectTable, projectMutating] + [activeGitHubProjectHost, client, githubProjectTable, projectMutating] ) const mutateProjectRowIssueType = useCallback( @@ -6294,6 +6285,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, issueTypeId: issueType?.id ?? null }, @@ -6329,7 +6321,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const requestProjectGitHubReviewers = useCallback( @@ -6350,6 +6342,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), reviewers }, { timeoutMs: 30_000 } @@ -6397,7 +6390,14 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectReviewersDraft, projectRowDetail] + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + projectMutating, + projectReviewersDraft, + projectRowDetail + ] ) const refreshProjectGitHubChecks = useCallback( @@ -6420,6 +6420,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, noCache: true }, @@ -6441,7 +6442,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const rerunProjectGitHubChecks = useCallback( @@ -6464,6 +6465,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, failedOnly }, @@ -6483,7 +6485,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const toggleProjectGitHubFileViewed = useCallback( @@ -6504,6 +6506,7 @@ export default function MobileTasksScreen() { 'github.setPRFileViewed', { repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), pullRequestId: projectRowDetail.pullRequestId, path: file.path, viewed @@ -6536,7 +6539,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const toggleProjectGitHubFileExpansion = useCallback( @@ -6570,6 +6573,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), path: file.path, oldPath: file.oldPath, status: file.status ?? 'modified', @@ -6593,7 +6597,14 @@ export default function MobileTasksScreen() { setPrFileLoadingPath(null) } }, - [client, expandedPrFilePath, findProjectRowRepo, prFileContents, projectRowDetail] + [ + activeGitHubProjectHost, + client, + expandedPrFilePath, + findProjectRowRepo, + prFileContents, + projectRowDetail + ] ) const addProjectGitHubFileReviewComment = useCallback( @@ -6625,6 +6636,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), commitId: projectRowDetail.headSha, path: file.path, line, @@ -6669,7 +6681,14 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, prFileCommentDrafts, projectMutating, projectRowDetail] + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + prFileCommentDrafts, + projectMutating, + projectRowDetail + ] ) const mergeProjectGitHubPullRequest = useCallback( @@ -6695,6 +6714,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), method }, { timeoutMs: 60_000 } @@ -6731,7 +6751,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] ) const toggleGitHubStatus = useCallback( diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index 4f5d186c7e4..ce1ef33cb96 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -10,7 +10,7 @@ import { PRCommentComposer } from './PRCommentComposer' import { formatPrCommentRelativeTime } from './pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' -export type PRCommentRepoSlug = { owner: string; repo: string } +export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } // Action handlers are passed from the comment actions hook (stable callbacks), so // adding them keeps the memo'd card from re-rendering on unrelated timeline changes. diff --git a/mobile/src/session/github-pr-mutations.test.ts b/mobile/src/session/github-pr-mutations.test.ts index d7d722ea966..52a6ef824ff 100644 --- a/mobile/src/session/github-pr-mutations.test.ts +++ b/mobile/src/session/github-pr-mutations.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '../transport/types' import { + fetchAddIssueComment, + fetchAddPRReviewCommentReply, fetchDeleteIssueComment, fetchMergePR, fetchResolveReviewThread, @@ -29,6 +31,7 @@ function clientRejecting(message: string) { } const WORKTREE_ID = 'repo-42::/path/to/wt' +const ENTERPRISE_PR_REPO = { owner: 'o', repo: 'r', host: 'github.acme.test' } describe('fetchResolveReviewThread / fetchUpdatePRTitle — bare-boolean host result', () => { it('treats an explicit true as success', async () => { @@ -102,12 +105,51 @@ describe('mutation transport rejection normalization', () => { }) }) +describe('Enterprise PR repo forwarding', () => { + it('forwards the host through title, root-comment, and review-reply RPCs', async () => { + const titleClient = clientReturning(okResponse(true)) + await fetchUpdatePRTitle(titleClient, WORKTREE_ID, { + prNumber: 7, + title: 'New title', + prRepo: ENTERPRISE_PR_REPO + }) + expect(titleClient.sendRequest).toHaveBeenCalledWith( + 'github.updatePRTitle', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + + const rootClient = clientReturning(okResponse({ ok: true })) + await fetchAddIssueComment(rootClient, WORKTREE_ID, { + prNumber: 7, + body: 'Root comment', + prRepo: ENTERPRISE_PR_REPO + }) + expect(rootClient.sendRequest).toHaveBeenCalledWith( + 'github.addIssueComment', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + + const replyClient = clientReturning(okResponse({ ok: true })) + await fetchAddPRReviewCommentReply(replyClient, WORKTREE_ID, { + prNumber: 7, + commentId: 42, + body: 'Reply', + prRepo: ENTERPRISE_PR_REPO + }) + expect(replyClient.sendRequest).toHaveBeenCalledWith( + 'github.addPRReviewCommentReply', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + }) +}) + describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed envelope', () => { it('sends owner/repo/commentId(+body) and reads the { ok } envelope', async () => { const editClient = clientReturning(okResponse({ ok: true })) const edit = await fetchUpdateIssueComment(editClient, { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5, body: 'edited' }) @@ -115,16 +157,23 @@ describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed e expect(editClient.sendRequest).toHaveBeenCalledWith('github.project.updateIssueCommentBySlug', { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5, body: 'edited' }) const delClient = clientReturning(okResponse({ ok: true })) - const del = await fetchDeleteIssueComment(delClient, { owner: 'o', repo: 'r', commentId: 5 }) + const del = await fetchDeleteIssueComment(delClient, { + owner: 'o', + repo: 'r', + host: 'github.acme.test', + commentId: 5 + }) expect(del).toEqual({ ok: true }) expect(delClient.sendRequest).toHaveBeenCalledWith('github.project.deleteIssueCommentBySlug', { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5 }) }) diff --git a/mobile/src/session/github-pr-mutations.ts b/mobile/src/session/github-pr-mutations.ts index 7d79518f089..d9fa04ce06a 100644 --- a/mobile/src/session/github-pr-mutations.ts +++ b/mobile/src/session/github-pr-mutations.ts @@ -1,6 +1,6 @@ import type { GitHubPRMergeMethod } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' -import { buildGithubPrParams, type GitHubPrRepoSlug } from './github-pr-rpc' +import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc' // Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts // stays under the max-lines budget. They mirror the read wrappers' shape but @@ -101,16 +101,10 @@ export async function fetchUpdatePRTitle( args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null } ): Promise { const params: Record = { prNumber: args.prNumber, title: args.title } - // updatePRTitle accepts prRepo for fork PRs, but it is not in the centralized - // METHODS_ACCEPTING_PR_REPO read allow-list — pass it explicitly so it reaches the - // host schema (which declares it optional/nullable). - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } const response = await sendRaw( client, 'github.updatePRTitle', - buildGithubPrParams('github.updatePRTitle', worktreeId, params) + buildGithubPrParams('github.updatePRTitle', worktreeId, params, { prRepo: args.prRepo }) ) if (!response.ok) { return { ok: false, error: response.error || 'Request failed: github.updatePRTitle' } @@ -147,48 +141,51 @@ export async function fetchSetPRAutoMerge( export async function fetchUpdatePRState( client: Pick, worktreeId: string, - args: { prNumber: number; state: 'open' | 'closed' } + args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // updatePRState does NOT accept prRepo (KTD3) — buildGithubPrParams omits it. return sendGithubPrMutation( client, 'github.updatePRState', - buildGithubPrParams('github.updatePRState', worktreeId, { - prNumber: args.prNumber, - updates: { state: args.state } - }) + buildGithubPrParams( + 'github.updatePRState', + worktreeId, + { prNumber: args.prNumber, updates: { state: args.state } }, + { prRepo: args.prRepo } + ) ) } export async function fetchRequestPRReviewers( client: Pick, worktreeId: string, - args: { prNumber: number; reviewers: string[] } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // requestPRReviewers does NOT accept prRepo (KTD3). return sendGithubPrMutation( client, 'github.requestPRReviewers', - buildGithubPrParams('github.requestPRReviewers', worktreeId, { - prNumber: args.prNumber, - reviewers: args.reviewers - }) + buildGithubPrParams( + 'github.requestPRReviewers', + worktreeId, + { prNumber: args.prNumber, reviewers: args.reviewers }, + { prRepo: args.prRepo } + ) ) } export async function fetchRemovePRReviewers( client: Pick, worktreeId: string, - args: { prNumber: number; reviewers: string[] } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // removePRReviewers does NOT accept prRepo (KTD3). return sendGithubPrMutation( client, 'github.removePRReviewers', - buildGithubPrParams('github.removePRReviewers', worktreeId, { - prNumber: args.prNumber, - reviewers: args.reviewers - }) + buildGithubPrParams( + 'github.removePRReviewers', + worktreeId, + { prNumber: args.prNumber, reviewers: args.reviewers }, + { prRepo: args.prRepo } + ) ) } @@ -222,16 +219,12 @@ export async function fetchAddPRReviewCommentReply( if (typeof args.line === 'number') { params.line = args.line } - // addPRReviewCommentReply accepts prRepo for fork PRs, but it is not in the - // centralized METHODS_ACCEPTING_PR_REPO allow-list (read-focused) — pass it - // explicitly so it reaches the host schema, which declares it optional. - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } return sendGithubPrMutation( client, 'github.addPRReviewCommentReply', - buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params) + buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, { + prRepo: args.prRepo + }) ) } @@ -246,13 +239,10 @@ export async function fetchAddIssueComment( body: args.body, type: 'pr' } - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } return sendGithubPrMutation( client, 'github.addIssueComment', - buildGithubPrParams('github.addIssueComment', worktreeId, params) + buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo }) ) } @@ -262,15 +252,17 @@ export async function fetchAddIssueComment( export async function fetchResolveReviewThread( client: Pick, worktreeId: string, - args: { threadId: string; resolve: boolean } + args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null } ): Promise { const response = await sendRaw( client, 'github.resolveReviewThread', - buildGithubPrParams('github.resolveReviewThread', worktreeId, { - threadId: args.threadId, - resolve: args.resolve - }) + buildGithubPrParams( + 'github.resolveReviewThread', + worktreeId, + { threadId: args.threadId, resolve: args.resolve }, + { prRepo: args.prRepo } + ) ) if (!response.ok) { return { @@ -292,11 +284,10 @@ export async function fetchResolveReviewThread( // GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads. export async function fetchUpdateIssueComment( client: Pick, - args: { owner: string; repo: string; commentId: number; body: string } + args: { owner: string; repo: string; host?: string; commentId: number; body: string } ): Promise { return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', { - owner: args.owner, - repo: args.repo, + ...githubPrRepoSlugParam(args), commentId: args.commentId, body: args.body }) @@ -305,11 +296,10 @@ export async function fetchUpdateIssueComment( // Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper. export async function fetchDeleteIssueComment( client: Pick, - args: { owner: string; repo: string; commentId: number } + args: { owner: string; repo: string; host?: string; commentId: number } ): Promise { return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', { - owner: args.owner, - repo: args.repo, + ...githubPrRepoSlugParam(args), commentId: args.commentId }) } @@ -317,9 +307,13 @@ export async function fetchDeleteIssueComment( export async function fetchRerunPRChecks( client: Pick, worktreeId: string, - args: { prNumber: number; headSha?: string | null; failedOnly?: boolean } + args: { + prNumber: number + headSha?: string | null + failedOnly?: boolean + prRepo?: GitHubPrRepoSlug | null + } ): Promise { - // rerunPRChecks does NOT accept prRepo (KTD3); headSha is a plain param here. const params: Record = { prNumber: args.prNumber } if (args.failedOnly !== undefined) { params.failedOnly = args.failedOnly @@ -330,6 +324,6 @@ export async function fetchRerunPRChecks( return sendGithubPrMutation( client, 'github.rerunPRChecks', - buildGithubPrParams('github.rerunPRChecks', worktreeId, params) + buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo }) ) } diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index 56904e482cd..a287aa4f616 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -103,13 +103,17 @@ describe('readPRForBranch', () => { const parsed = readPRForBranch({ number: 3, state: 'open', - prRepo: { owner: 'forkOwner', repo: 'forkRepo' }, + prRepo: { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' }, mergeMethodSettings: { defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } } }) - expect(parsed?.prRepo).toEqual({ owner: 'forkOwner', repo: 'forkRepo' }) + expect(parsed?.prRepo).toEqual({ + owner: 'forkOwner', + repo: 'forkRepo', + host: 'github.acme.test' + }) expect(parsed?.mergeMethodSettings).toEqual({ defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } @@ -266,7 +270,7 @@ describe('readAssignableUsers', () => { }) describe('buildGithubPrParams — method-aware prRepo / headSha', () => { - const fork = { owner: 'forkOwner', repo: 'forkRepo' } + const fork = { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' } it('reuses mobileRepoSelectorFromWorktreeId for the repo selector', () => { const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 }) @@ -278,9 +282,20 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => { for (const method of [ 'github.prChecks', 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updatePRState', + 'github.requestPRReviewers', + 'github.removePRReviewers', 'github.mergePR', 'github.setPRAutoMerge', - 'github.prComments' + 'github.updatePRTitle', + 'github.prComments', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.addIssueComment', + 'github.addPRReviewCommentReply' ]) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect(params.prRepo).toEqual(fork) @@ -288,13 +303,7 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => { }) it('omits prRepo for methods that reject it', () => { - for (const method of [ - 'github.updatePRState', - 'github.requestPRReviewers', - 'github.removePRReviewers', - 'github.listAssignableUsers', - 'github.rerunPRChecks' - ]) { + for (const method of ['github.repoSlug', 'github.prForBranch', 'github.listAssignableUsers']) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect('prRepo' in params).toBe(false) } @@ -380,12 +389,15 @@ describe('fetch wrappers', () => { expect('headSha' in (params as object)).toBe(false) }) - it('fetchGithubRepoSlug returns the slug for a github repo, null otherwise', async () => { + it('fetchGithubRepoSlug preserves an Enterprise host, and returns null otherwise', async () => { const found = await fetchGithubRepoSlug( - mockClient(okResponse({ owner: 'o', repo: 'r' })).client, + mockClient(okResponse({ owner: 'o', repo: 'r', host: 'github.acme.test' })).client, WORKTREE_ID ) - expect(found).toEqual({ ok: true, result: { owner: 'o', repo: 'r' } }) + expect(found).toEqual({ + ok: true, + result: { owner: 'o', repo: 'r', host: 'github.acme.test' } + }) const none = await fetchGithubRepoSlug(mockClient(okResponse(null)).client, WORKTREE_ID) expect(none).toEqual({ ok: true, result: null }) }) diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 8fd11f710a8..0ae009d926a 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -31,20 +31,35 @@ export { // Why: a fork PR's head lives in a different owner/repo; the host's SlugRepo // (`{ owner, repo }`) identifies it. Only a subset of github.* methods accept it. -export type GitHubPrRepoSlug = { owner: string; repo: string } +// Why: `host` must survive the RPC boundary or GHES actions on the host fall +// back to a same-named github.com repo (src/shared/types.ts identity contract). +export type GitHubPrRepoSlug = { owner: string; repo: string; host?: string } + +export function githubPrRepoSlugParam(slug: GitHubPrRepoSlug): Record { + return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) } +} export type GitHubPrReadOutcome = { ok: true; result: T } | { ok: false; error: string } -// Why: `prRepo` is method-asymmetric (KTD3). These are the only github.* methods -// whose host schema (SlugRepo on PullRequest/PullRequestChecks/PullRequestCheckDetails) -// accepts it; the rest reject the key. Centralizing the allow-list keeps a fork's -// prRepo from leaking into a schema that would reject it. +// Why: `prRepo` remains method-asymmetric. Keep the RPC schema allow-list here +// so fork/GHES identity reaches every PR-scoped read or mutation that accepts it. const METHODS_ACCEPTING_PR_REPO = new Set([ 'github.prChecks', 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updatePRState', + 'github.requestPRReviewers', + 'github.removePRReviewers', 'github.mergePR', 'github.setPRAutoMerge', - 'github.prComments' + 'github.updatePRTitle', + 'github.prComments', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.addIssueComment', + 'github.addPRReviewCommentReply' ]) // Why: only github.prChecks declares a `headSha` param (PullRequestCheckDetails @@ -62,7 +77,7 @@ export function buildGithubPrParams( ...params } if (options?.prRepo && METHODS_ACCEPTING_PR_REPO.has(method) && !('prRepo' in built)) { - built.prRepo = { owner: options.prRepo.owner, repo: options.prRepo.repo } + built.prRepo = githubPrRepoSlugParam(options.prRepo) } if (options?.headSha && METHODS_ACCEPTING_HEAD_SHA.has(method) && !('headSha' in built)) { built.headSha = options.headSha @@ -107,7 +122,10 @@ export async function fetchGithubRepoSlug( const record = value as Record const owner = record.owner const repo = record.repo - return typeof owner === 'string' && typeof repo === 'string' ? { owner, repo } : null + const host = record.host + return typeof owner === 'string' && typeof repo === 'string' + ? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) } + : null } ) } diff --git a/mobile/src/session/github-pr-value-readers.test.ts b/mobile/src/session/github-pr-value-readers.test.ts index cfd3142cff7..0b78d11dbb1 100644 --- a/mobile/src/session/github-pr-value-readers.test.ts +++ b/mobile/src/session/github-pr-value-readers.test.ts @@ -9,6 +9,14 @@ describe('readRepoIdentity', () => { }) }) + it('preserves an Enterprise host', () => { + expect(readRepoIdentity({ owner: 'octo', repo: 'orca', host: 'github.acme.test' })).toEqual({ + owner: 'octo', + repo: 'orca', + host: 'github.acme.test' + }) + }) + it('drops a non-record value', () => { expect(readRepoIdentity(null)).toBeUndefined() expect(readRepoIdentity('octo/orca')).toBeUndefined() diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index 6ba8ccd6954..1756d51c59a 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -153,7 +153,10 @@ export function readRepoIdentity(value: unknown): GitHubRepositoryIdentity | und if (!owner || !repo) { return undefined } - return { owner, repo } + // Why: dropping `host` here would strip the GHES identity before every + // subsequent PR RPC, forcing the host to re-derive it per call. + const host = readString(value.host) + return { owner, repo, ...(host ? { host } : {}) } } function readMergeMethod(value: unknown): GitHubPRMergeMethod | undefined { diff --git a/mobile/src/session/pr-action-mutation-contract.ts b/mobile/src/session/pr-action-mutation-contract.ts new file mode 100644 index 00000000000..a2fcdb271df --- /dev/null +++ b/mobile/src/session/pr-action-mutation-contract.ts @@ -0,0 +1,38 @@ +import type { GitHubPRMergeMethod } from '../../../src/shared/types' +import type { GitHubPrMutationOutcome } from './github-pr-mutations' +import type { GitHubPrRepoSlug } from './github-pr-rpc' + +export type PrActionMutations = { + mergePR: (args: { + prNumber: number + method?: GitHubPRMergeMethod + prRepo?: GitHubPrRepoSlug | null + }) => Promise + setPRAutoMerge: (args: { + prNumber: number + enabled: boolean + method?: GitHubPRMergeMethod + prRepo?: GitHubPrRepoSlug | null + }) => Promise + updatePRState: (args: { + prNumber: number + state: 'open' | 'closed' + prRepo?: GitHubPrRepoSlug | null + }) => Promise + requestReviewers: (args: { + prNumber: number + reviewers: string[] + prRepo?: GitHubPrRepoSlug | null + }) => Promise + removeReviewers: (args: { + prNumber: number + reviewers: string[] + prRepo?: GitHubPrRepoSlug | null + }) => Promise + rerunChecks: (args: { + prNumber: number + headSha?: string | null + failedOnly?: boolean + prRepo?: GitHubPrRepoSlug | null + }) => Promise +} diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index d879454c50c..3656b2732f6 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -198,4 +198,43 @@ describe('PrActionsEngine — PR identity changes', () => { await action expect(refetch).not.toHaveBeenCalled() }) + + it('clears in-flight state when only the GitHub host changes', async () => { + const slow = deferred() + const mutations: PrActionMutations = { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => slow.promise, + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + } + const refetch = vi.fn() + const onChange = vi.fn() + const baseConfig = { + mutations, + prNumber: 1, + refetch, + onChange + } + const engine = new PrActionsEngine({ + ...baseConfig, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + + const action = engine.setAutoMerge(true) + expect(engine.resolveAutoMerge(false)).toBe(true) + expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true) + + engine.updateConfig({ + ...baseConfig, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } + }) + expect(engine.resolveAutoMerge(false)).toBe(false) + expect(engine.busy).toBeNull() + + slow.resolve({ ok: true }) + await action + expect(refetch).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index 7c34954534d..ce19c9bbbf9 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -1,45 +1,18 @@ import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types' +import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state' import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence' import type { GitHubPrMutationOutcome } from './github-pr-mutations' import type { GitHubPrRepoSlug } from './github-pr-rpc' +import type { PrActionMutations } from './pr-action-mutation-contract' + +export type { PrActionMutations } from './pr-action-mutation-contract' // Pure (React-free) engine for the PR mutation actions: owns optimistic fields, // busy/error/blocked state, and the success/transient/permanent routing. The hook // is a thin adapter that subscribes to `onChange` and exposes these methods. Kept // React-free so the U6 action logic is unit-testable with injected fakes. -export type PrActionMutations = { - mergePR: (args: { - prNumber: number - method?: GitHubPRMergeMethod - prRepo?: GitHubPrRepoSlug | null - }) => Promise - setPRAutoMerge: (args: { - prNumber: number - enabled: boolean - method?: GitHubPRMergeMethod - prRepo?: GitHubPrRepoSlug | null - }) => Promise - updatePRState: (args: { - prNumber: number - state: 'open' | 'closed' - }) => Promise - requestReviewers: (args: { - prNumber: number - reviewers: string[] - }) => Promise - removeReviewers: (args: { - prNumber: number - reviewers: string[] - }) => Promise - rerunChecks: (args: { - prNumber: number - headSha?: string | null - failedOnly?: boolean - }) => Promise -} - export type PrActionBusyKey = | { kind: 'merge' } | { kind: 'autoMerge' } @@ -68,7 +41,7 @@ export type PrActionsEngineConfig = { } function prActionsIdentity(cfg: PrActionsEngineConfig): string { - const repo = cfg.prRepo ? `${cfg.prRepo.owner}/${cfg.prRepo.repo}` : '' + const repo = cfg.prRepo ? githubRepoIdentityKey(cfg.prRepo) : '' return `${cfg.prNumber}:${repo}` } @@ -260,7 +233,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.updatePRState({ prNumber: cfg.prNumber, - state + state, + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => this.stateField.settleSuccess(seq), @@ -281,7 +255,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.requestReviewers({ prNumber: cfg.prNumber, - reviewers: [login] + reviewers: [login], + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => field.settleSuccess(seq), @@ -302,7 +277,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.removeReviewers({ prNumber: cfg.prNumber, - reviewers: [login] + reviewers: [login], + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => field.settleSuccess(seq), @@ -322,7 +298,8 @@ export class PrActionsEngine { const outcome = await cfg.mutations.rerunChecks({ prNumber: cfg.prNumber, headSha: cfg.headSha, - failedOnly: true + failedOnly: true, + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} }) } finally { diff --git a/mobile/src/session/pr-comment-actions.test.ts b/mobile/src/session/pr-comment-actions.test.ts index 4d70428845d..bb6f1758b24 100644 --- a/mobile/src/session/pr-comment-actions.test.ts +++ b/mobile/src/session/pr-comment-actions.test.ts @@ -134,13 +134,19 @@ describe('canEditComment / canDeleteComment', () => { describe('buildEditCommentParams / buildDeleteCommentParams', () => { it('build slug-addressed params', () => { - const slug = { owner: 'o', repo: 'r' } + const slug = { owner: 'o', repo: 'r', host: 'github.acme.test' } expect(buildEditCommentParams(slug, 42, 'new body')).toEqual({ owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 42, body: 'new body' }) - expect(buildDeleteCommentParams(slug, 42)).toEqual({ owner: 'o', repo: 'r', commentId: 42 }) + expect(buildDeleteCommentParams(slug, 42)).toEqual({ + owner: 'o', + repo: 'r', + host: 'github.acme.test', + commentId: 42 + }) }) }) diff --git a/mobile/src/session/pr-comment-actions.ts b/mobile/src/session/pr-comment-actions.ts index 195d2480d87..012500c62da 100644 --- a/mobile/src/session/pr-comment-actions.ts +++ b/mobile/src/session/pr-comment-actions.ts @@ -1,4 +1,4 @@ -import type { PRComment, PRState } from '../../../src/shared/types' +import type { GitHubOwnerRepo, PRComment, PRState } from '../../../src/shared/types' // Pure helpers for the interactive PR comment timeline (reply / resolve / add // root comment). Kept free of React/native imports so they unit-test under the @@ -87,35 +87,46 @@ export function isMutablePRConversationComment( // can hide the affordance rather than firing a doomed request. export function canEditComment( comment: Pick, - prRepo: { owner: string; repo: string } | null | undefined + prRepo: GitHubOwnerRepo | null | undefined ): boolean { return Boolean(prRepo) && isMutablePRConversationComment(comment) } export function canDeleteComment( comment: Pick, - prRepo: { owner: string; repo: string } | null | undefined + prRepo: GitHubOwnerRepo | null | undefined ): boolean { return Boolean(prRepo) && isMutablePRConversationComment(comment) } -export type EditCommentParams = { owner: string; repo: string; commentId: number; body: string } +export type EditCommentParams = GitHubOwnerRepo & { commentId: number; body: string } export function buildEditCommentParams( - prRepo: { owner: string; repo: string }, + prRepo: GitHubOwnerRepo, commentId: number, body: string ): EditCommentParams { - return { owner: prRepo.owner, repo: prRepo.repo, commentId, body } + return { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}), + commentId, + body + } } -export type DeleteCommentParams = { owner: string; repo: string; commentId: number } +export type DeleteCommentParams = GitHubOwnerRepo & { commentId: number } export function buildDeleteCommentParams( - prRepo: { owner: string; repo: string }, + prRepo: GitHubOwnerRepo, commentId: number ): DeleteCommentParams { - return { owner: prRepo.owner, repo: prRepo.repo, commentId } + return { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}), + commentId + } } // The composer disables submit on empty/whitespace input (host rejects empty body). diff --git a/mobile/src/session/use-mobile-pr-actions.test.ts b/mobile/src/session/use-mobile-pr-actions.test.ts index e8efa60094d..dd022bb25f7 100644 --- a/mobile/src/session/use-mobile-pr-actions.test.ts +++ b/mobile/src/session/use-mobile-pr-actions.test.ts @@ -10,6 +10,7 @@ import { } from './github-pr-mutations' const WORKTREE_ID = 'repo-42::/path/to/wt' +const ENTERPRISE_PR_REPO = { owner: 'fork', repo: 'proj', host: 'github.acme.test' } function okStatus(): RpcResponse { return { id: 'x', ok: true, result: { ok: true }, _meta: { runtimeId: 'r' } } @@ -46,13 +47,17 @@ function fakeMutations(overrides: Partial = {}): PrActionMuta } } -function makeEngine(mutations: PrActionMutations, refetch = vi.fn(async () => {})) { +function makeEngine( + mutations: PrActionMutations, + refetch = vi.fn(async () => {}), + prRepo: typeof ENTERPRISE_PR_REPO | null = null +) { const onChange = vi.fn() const engine = new PrActionsEngine({ mutations, prNumber: 7, headSha: 'abc123', - prRepo: null, + prRepo, refetch, onChange }) @@ -67,7 +72,7 @@ describe('mutation wrappers — prRepo + param shapes', () => { await fetchMergePR(client, WORKTREE_ID, { prNumber: 7, method: 'squash', - prRepo: { owner: 'fork', repo: 'proj' } + prRepo: ENTERPRISE_PR_REPO }) expect(sendRequest).toHaveBeenCalledWith( 'github.mergePR', @@ -75,17 +80,25 @@ describe('mutation wrappers — prRepo + param shapes', () => { repo: 'id:repo-42', prNumber: 7, method: 'squash', - prRepo: { owner: 'fork', repo: 'proj' } + prRepo: ENTERPRISE_PR_REPO }) ) }) - it('updatePRState close carries NO prRepo and the state update', async () => { + it('updatePRState carries the host-qualified PR repo and state update', async () => { const { client, sendRequest } = mockClient(okStatus()) - await fetchUpdatePRState(client, WORKTREE_ID, { prNumber: 7, state: 'closed' }) + await fetchUpdatePRState(client, WORKTREE_ID, { + prNumber: 7, + state: 'closed', + prRepo: ENTERPRISE_PR_REPO + }) const [, params] = sendRequest.mock.calls[0] - expect(params).toEqual({ repo: 'id:repo-42', prNumber: 7, updates: { state: 'closed' } }) - expect(params).not.toHaveProperty('prRepo') + expect(params).toEqual({ + repo: 'id:repo-42', + prNumber: 7, + updates: { state: 'closed' }, + prRepo: ENTERPRISE_PR_REPO + }) }) it('updatePRState reopen passes state:open', async () => { @@ -95,27 +108,43 @@ describe('mutation wrappers — prRepo + param shapes', () => { expect((params.updates as { state: string }).state).toBe('open') }) - it('request/remove reviewers carry NO prRepo even when a fork is in play', async () => { + it('request/remove reviewers carry the host-qualified PR repo', async () => { const { client, sendRequest } = mockClient(okStatus()) - await fetchRequestPRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['alice'] }) - await fetchRemovePRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['bob'] }) - for (const [, params] of sendRequest.mock.calls) { - expect(params).not.toHaveProperty('prRepo') - } - expect(sendRequest.mock.calls[0][1]).toMatchObject({ reviewers: ['alice'] }) - expect(sendRequest.mock.calls[1][1]).toMatchObject({ reviewers: ['bob'] }) + await fetchRequestPRReviewers(client, WORKTREE_ID, { + prNumber: 7, + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) + await fetchRemovePRReviewers(client, WORKTREE_ID, { + prNumber: 7, + reviewers: ['bob'], + prRepo: ENTERPRISE_PR_REPO + }) + expect(sendRequest.mock.calls[0][1]).toMatchObject({ + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) + expect(sendRequest.mock.calls[1][1]).toMatchObject({ + reviewers: ['bob'], + prRepo: ENTERPRISE_PR_REPO + }) }) - it('rerunPRChecks carries failedOnly + headSha and NO prRepo', async () => { + it('rerunPRChecks carries failedOnly, headSha, and the host-qualified PR repo', async () => { const { client, sendRequest } = mockClient(okStatus()) await fetchRerunPRChecks(client, WORKTREE_ID, { prNumber: 7, headSha: 'abc', - failedOnly: true + failedOnly: true, + prRepo: ENTERPRISE_PR_REPO }) const [, params] = sendRequest.mock.calls[0] - expect(params).toMatchObject({ prNumber: 7, failedOnly: true, headSha: 'abc' }) - expect(params).not.toHaveProperty('prRepo') + expect(params).toMatchObject({ + prNumber: 7, + failedOnly: true, + headSha: 'abc', + prRepo: ENTERPRISE_PR_REPO + }) }) it('a host { ok:false, error } result surfaces as a failure outcome', async () => { @@ -155,14 +184,14 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => { const mutations = fakeMutations({ setPRAutoMerge: vi.fn(async () => ({ ok: false as const, error: 'connection lost' })) }) - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) // authoritative = false; user enables. await engine.setAutoMerge(true, 'squash') expect(mutations.setPRAutoMerge).toHaveBeenCalledWith({ prNumber: 7, enabled: true, method: 'squash', - prRepo: null + prRepo: ENTERPRISE_PR_REPO }) // Reverted to authoritative after transient failure. expect(engine.resolveAutoMerge(false)).toBe(false) @@ -182,20 +211,32 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => { describe('PrActionsEngine — updateState close/reopen', () => { it('close fires state:closed and reopen fires state:open', async () => { const mutations = fakeMutations() - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.updateState('closed') await engine.updateState('open') - expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { prNumber: 7, state: 'closed' }) - expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { prNumber: 7, state: 'open' }) + expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { + prNumber: 7, + state: 'closed', + prRepo: ENTERPRISE_PR_REPO + }) + expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { + prNumber: 7, + state: 'open', + prRepo: ENTERPRISE_PR_REPO + }) }) }) describe('PrActionsEngine — reviewers', () => { - it('requestReviewer adds via requestReviewers with a single login (no prRepo at engine level)', async () => { + it('requestReviewer adds via requestReviewers with a host-qualified PR repo', async () => { const mutations = fakeMutations() - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.requestReviewer('alice') - expect(mutations.requestReviewers).toHaveBeenCalledWith({ prNumber: 7, reviewers: ['alice'] }) + expect(mutations.requestReviewers).toHaveBeenCalledWith({ + prNumber: 7, + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) }) it('removeReviewer optimistic revert on transient failure', async () => { @@ -213,12 +254,13 @@ describe('PrActionsEngine — reviewers', () => { describe('PrActionsEngine — rerun checks', () => { it('fires failedOnly:true with headSha and refetches on success', async () => { const mutations = fakeMutations() - const { engine, refetch } = makeEngine(mutations) + const { engine, refetch } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.rerunFailingChecks() expect(mutations.rerunChecks).toHaveBeenCalledWith({ prNumber: 7, headSha: 'abc123', - failedOnly: true + failedOnly: true, + prRepo: ENTERPRISE_PR_REPO }) expect(refetch).toHaveBeenCalledOnce() }) diff --git a/mobile/src/session/use-mobile-pr-comment-actions.ts b/mobile/src/session/use-mobile-pr-comment-actions.ts index f0909b9e03a..38096bad833 100644 --- a/mobile/src/session/use-mobile-pr-comment-actions.ts +++ b/mobile/src/session/use-mobile-pr-comment-actions.ts @@ -30,7 +30,11 @@ export type PrCommentMutations = { line?: number prRepo?: GitHubPrRepoSlug | null }) => Promise - resolveThread: (args: { threadId: string; resolve: boolean }) => Promise + resolveThread: (args: { + threadId: string + resolve: boolean + prRepo?: GitHubPrRepoSlug | null + }) => Promise addRootComment: (args: { prNumber: number body: string @@ -39,12 +43,14 @@ export type PrCommentMutations = { editComment: (args: { owner: string repo: string + host?: string commentId: number body: string }) => Promise deleteComment: (args: { owner: string repo: string + host?: string commentId: number }) => Promise } @@ -169,9 +175,9 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) { if (!mutations || !params) { return Promise.resolve(false) } - return run(resolveKey(params.threadId), () => mutations.resolveThread(params)) + return run(resolveKey(params.threadId), () => mutations.resolveThread({ ...params, prRepo })) }, - [mutations, run] + [mutations, prRepo, run] ) const addRootComment = useCallback( diff --git a/mobile/src/tasks/github-project-host-routing-source.test.ts b/mobile/src/tasks/github-project-host-routing-source.test.ts new file mode 100644 index 00000000000..05781d9f6f8 --- /dev/null +++ b/mobile/src/tasks/github-project-host-routing-source.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8') + +describe('mobile GitHub Project host routing boundary', () => { + it('host-qualifies every Project RPC request', () => { + const calls = [...source.matchAll(/['"](github\.project\.[^'"]+)['"]/g)] + expect(calls.length).toBeGreaterThan(10) + for (const call of calls) { + const request = source.slice(call.index, call.index + 700) + expect(request, `${call[1]} must carry a host`).toMatch(/\bhost\s*:/) + } + }) + + it('pins Project-row PR actions to the row repository identity', () => { + const actions = source.slice(source.indexOf('const toggleProjectGitHubReviewThread')) + for (const method of [ + 'github.resolveReviewThread', + 'github.addPRReviewCommentReply', + 'github.addIssueComment', + 'github.requestPRReviewers', + 'github.prChecks', + 'github.rerunPRChecks', + 'github.setPRFileViewed', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.mergePR' + ]) { + const offset = actions.indexOf(`'${method}'`) + expect(offset, `${method} must remain wired in the Project action path`).toBeGreaterThan(-1) + expect(actions.slice(offset, offset + 700), `${method} must carry prRepo`).toContain( + 'prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost)' + ) + } + }) + + it('pins discovery to github.com while pasted URLs supply their parsed host', () => { + expect(source).toContain("'github.project.listAccessible', {\n host: 'github.com'") + expect(source).toContain('host: githubProjectHost(parsed.host)') + }) +}) diff --git a/mobile/src/tasks/github-project-reference.test.ts b/mobile/src/tasks/github-project-reference.test.ts new file mode 100644 index 00000000000..d083d96ccc7 --- /dev/null +++ b/mobile/src/tasks/github-project-reference.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { parseGitHubProjectInput } from './github-project-reference' + +describe('parseGitHubProjectInput', () => { + it('keeps legacy owner/number input hostless', () => { + expect(parseGitHubProjectInput('Acme/42')).toEqual({ owner: 'Acme', number: 42 }) + }) + + it('preserves GitHub Enterprise hosts and view numbers', () => { + expect( + parseGitHubProjectInput('https://GitHub.Acme-Corp.com:8443/orgs/Acme/projects/42/views/7') + ).toEqual({ + owner: 'Acme', + number: 42, + host: 'github.acme-corp.com:8443', + viewNumber: 7 + }) + }) + + it('accepts github.com user Project URLs', () => { + expect(parseGitHubProjectInput('http://github.com/users/octocat/projects/3')).toEqual({ + owner: 'octocat', + number: 3, + host: 'github.com' + }) + }) + + it('rejects credentials, invalid routes, and unsafe integers', () => { + for (const input of [ + 'https://user:token@github.acme.test/orgs/acme/projects/1', + 'https://github.acme.test/acme/projects/1', + 'https://github.acme.test/orgs/acme/projects/1/files', + 'https://github.acme.test/orgs/acme/projects/9007199254740992', + 'https://github.acme.test/orgs/acme/projects/1/views/0' + ]) { + expect(parseGitHubProjectInput(input)).toBeNull() + } + }) +}) diff --git a/mobile/src/tasks/github-project-reference.ts b/mobile/src/tasks/github-project-reference.ts new file mode 100644 index 00000000000..00047f15f53 --- /dev/null +++ b/mobile/src/tasks/github-project-reference.ts @@ -0,0 +1,84 @@ +import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity' + +export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType'] +export type GitHubProjectRef = GitHubProjectIdentity +export type GitHubProjectSettings = { + pinned: GitHubProjectRef[] + recent: Array + lastViewByProject: Record + activeProject: GitHubProjectRef | null +} +export type GitHubProjectSummary = GitHubProjectRef & { + id: string + title: string + url: string + source: string +} +export type GitHubProjectPartialFailure = { owner: string; message: string } +export type GitHubProjectViewSummary = { + id: string + number: number + name: string + layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' +} + +export type ParsedGitHubProjectInput = { + owner: string + number: number + host?: string + viewNumber?: number +} + +const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ + +function positiveInteger(value: string | undefined): number | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +export function parseGitHubProjectInput(input: string): ParsedGitHubProjectInput | null { + const trimmed = input.trim() + const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) + if (short) { + const number = positiveInteger(short[2]) + return number ? { owner: short[1]!, number } : null + } + + try { + const url = new URL(trimmed) + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + !url.host + ) { + return null + } + const parts = url.pathname.split('/').filter(Boolean) + const hasView = parts.length === 6 && parts[4] === 'views' + if ( + (parts[0] !== 'orgs' && parts[0] !== 'users') || + !OWNER_RE.test(parts[1] ?? '') || + parts[2] !== 'projects' || + (parts.length !== 4 && !hasView) + ) { + return null + } + const number = positiveInteger(parts[3]) + const viewNumber = hasView ? positiveInteger(parts[5]) : null + if (!number || (hasView && !viewNumber)) { + return null + } + return { + owner: parts[1]!, + number, + host: url.host.toLowerCase(), + ...(viewNumber ? { viewNumber } : {}) + } + } catch { + return null + } +} diff --git a/mobile/src/tasks/github-project-repo-match.test.ts b/mobile/src/tasks/github-project-repo-match.test.ts index 7979f690d6e..bce71393dab 100644 --- a/mobile/src/tasks/github-project-repo-match.test.ts +++ b/mobile/src/tasks/github-project-repo-match.test.ts @@ -20,7 +20,10 @@ describe('GitHub project repo matching', () => { it('matches project rows by resolved repo slug before path/display heuristics', () => { expect( findRepoForGitHubProjectRepository('stablyai/orca', repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + } }) ).toBe(repos[0]) }) @@ -28,8 +31,14 @@ describe('GitHub project repo matching', () => { it('does not pick a repo when resolved slugs are ambiguous', () => { expect( findRepoForGitHubProjectRepository('stablyai/orca', repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }, - 'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + }, + 'repo-2': { + path: '/Users/me/other', + repository: { owner: 'stablyai', repo: 'orca' } + } }) ).toBeNull() }) @@ -56,7 +65,10 @@ describe('GitHub project repo matching', () => { 'stablyai/orca', [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], { - 'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' } + 'repo-1': { + path: '/Users/me/stablyai/orca', + repository: { owner: 'fork', repo: 'orca' } + } } ) ).toBeNull() @@ -71,8 +83,46 @@ describe('GitHub project repo matching', () => { expect( filterGitHubProjectRowsForRepos(rows, repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + } }).map((row) => row.id) ).toEqual(['row-1']) }) + + it('matches same-named repositories only on the active Project host', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + repos, + { + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca', host: 'github.com' } + }, + 'repo-2': { + path: '/Users/me/other', + repository: { + owner: 'stablyai', + repo: 'orca', + host: 'github.acme-corp.com' + } + } + }, + 'github.acme-corp.com' + ) + ).toBe(repos[1]) + }) + + it('does not use hostless path heuristics for Enterprise Project rows', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], + {}, + 'github.acme-corp.com' + ) + ).toBeNull() + }) }) diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts index bf70f71dab7..c4d1e1d2ea6 100644 --- a/mobile/src/tasks/github-project-repo-match.ts +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -1,3 +1,8 @@ +import { + githubRepoIdentityKey, + isDefaultGitHubHost +} from '../../../src/shared/github-repository-identity-key' + export type GitHubProjectRepoMatch = { id: string path: string @@ -6,13 +11,13 @@ export type GitHubProjectRepoMatch = { export type GitHubRepoSlugCacheEntry = { path: string - slug: string | null + repository: { owner: string; repo: string; host?: string } | null } type CachedSlugState = | { status: 'missing' } | { status: 'stale' } - | { status: 'resolved'; slug: string | null } + | { status: 'resolved'; repository: GitHubRepoSlugCacheEntry['repository'] } export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null { const trimmed = value?.trim() @@ -37,13 +42,14 @@ function cachedSlugStateForRepo( if (cached.path !== repo.path) { return { status: 'stale' } } - return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) } + return { status: 'resolved', repository: cached.repository } } export function findRepoForGitHubProjectRepository( repository: string | null | undefined, repos: GitHubProjectRepoMatch[], - slugsByRepoId: Record = {} + slugsByRepoId: Record = {}, + projectHost?: string ): GitHubProjectRepoMatch | null { const slug = normalizeGitHubRepositorySlug(repository) if (!slug) { @@ -53,9 +59,18 @@ export function findRepoForGitHubProjectRepository( const slugStates = new Map( repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)]) ) + const requestedIdentityKey = githubRepoIdentityKey({ + owner: slug.split('/')[0]!, + repo: slug.split('/')[1]!, + ...(projectHost ? { host: projectHost } : {}) + }) const slugMatches = repos.filter((repo) => { const state = slugStates.get(repo.id) - return state?.status === 'resolved' && state.slug === slug + return ( + state?.status === 'resolved' && + state.repository !== null && + githubRepoIdentityKey(state.repository) === requestedIdentityKey + ) }) if (slugMatches.length === 1) { return slugMatches[0]! @@ -64,10 +79,16 @@ export function findRepoForGitHubProjectRepository( return null } + if (!isDefaultGitHubHost(projectHost)) { + // Why: display names and local paths contain no host evidence, so using + // them for GHES rows could bind an Enterprise item to a github.com repo. + return null + } + return ( repos.find((repo) => { const state = slugStates.get(repo.id) - if (state?.status === 'resolved' && state.slug !== null) { + if (state?.status === 'resolved' && state.repository !== null) { return false } const display = repo.displayName.trim().toLowerCase() @@ -82,9 +103,12 @@ export function filterGitHubProjectRowsForRepos< >( rows: readonly Row[], repos: GitHubProjectRepoMatch[], - slugsByRepoId: Record = {} + slugsByRepoId: Record = {}, + projectHost?: string ): Row[] { return rows.filter((row) => - Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId)) + Boolean( + findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId, projectHost) + ) ) } diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts index f7c724e7637..4cd34cb0057 100644 --- a/mobile/src/tasks/smart-source-paste-intent.test.ts +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { deriveRepoSlug, findRepoMatchingSlug, findRepoMatchingSlugForPaste, + lookupGitHubItemByOwnerRepo, resolvePasteIntent, type PasteRepoCandidate } from './smart-source-paste-intent' @@ -12,7 +13,7 @@ describe('resolvePasteIntent', () => { it('classifies a GitHub issue/PR URL as a github-link', () => { expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ kind: 'github-link', - link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + link: { slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, number: 12, type: 'pr' } }) }) @@ -47,6 +48,14 @@ describe('deriveRepoSlug', () => { }) }) + it('preserves an Enterprise upstream host', () => { + expect( + deriveRepoSlug({ + upstream: { owner: 'up', repo: 'stream', host: 'github.corp.example' } + }) + ).toEqual({ owner: 'up', repo: 'stream', host: 'github.corp.example' }) + }) + it('parses an SSH remote URL', () => { expect( deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) @@ -78,6 +87,16 @@ describe('findRepoMatchingSlug', () => { expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() }) + it('does not match a GHES link to a same-named github.com repo', () => { + expect( + findRepoMatchingSlug(repos, { + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example' + }) + ).toBeNull() + }) + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { const calls: string[] = [] const client = { @@ -86,7 +105,10 @@ describe('findRepoMatchingSlug', () => { calls.push(repo) return { ok: true, - result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + result: + repo === 'id:b' + ? { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' } + : null } } } as unknown as RpcClient @@ -94,7 +116,7 @@ describe('findRepoMatchingSlug', () => { findRepoMatchingSlugForPaste( client, repos, - { owner: 'enterprise', repo: 'widgets' }, + { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' }, new Map() ) ).resolves.toMatchObject({ id: 'b' }) @@ -139,4 +161,26 @@ describe('findRepoMatchingSlug', () => { ) ).resolves.toBeNull() }) + + it('sends the pasted host through the exact owner/repo lookup RPC', async () => { + const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: null }) + const client = { sendRequest } as unknown as RpcClient + + await lookupGitHubItemByOwnerRepo( + client, + 'repo-1', + { owner: 'acme', repo: 'widgets', host: 'github.corp.example' }, + 7, + 'pr' + ) + + expect(sendRequest).toHaveBeenCalledWith('github.workItemByOwnerRepo', { + repo: 'id:repo-1', + owner: 'acme', + ownerRepo: 'widgets', + host: 'github.corp.example', + number: 7, + type: 'pr' + }) + }) }) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 72c23a3737f..00f4c0d9ee7 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -9,6 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' +import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' // A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived // best-effort from the repo's remote metadata. @@ -58,11 +59,15 @@ export function resolvePasteIntent(query: string): PasteIntent { // Pure: derive an owner/repo slug from a repo's remote metadata so a pasted // cross-repo URL can be matched to a locally known repo. export function deriveRepoSlug(repo: { - upstream?: { owner: string; repo: string } | null + upstream?: { owner: string; repo: string; host?: string } | null gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null }): RepoSlug | null { if (repo.upstream?.owner && repo.upstream.repo) { - return { owner: repo.upstream.owner, repo: repo.upstream.repo } + return { + owner: repo.upstream.owner, + repo: repo.upstream.repo, + ...(repo.upstream.host ? { host: repo.upstream.host } : {}) + } } const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) @@ -76,9 +81,7 @@ function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { if (!a || !b) { return false } - return ( - a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() - ) + return githubRepoIdentityKey(a) === githubRepoIdentityKey(b) } export function findRepoMatchingSlug( @@ -148,6 +151,7 @@ export async function lookupGitHubItemByOwnerRepo( repo: `id:${repoId}`, owner: slug.owner, ownerRepo: slug.repo, + ...(slug.host ? { host: slug.host } : {}), number, type }) diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 1d0c88dafd5..42edcd48e8a 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -22,7 +22,11 @@ const DEBOUNCE_MS = 200 const RESULT_LIMIT = 36 export type SmartCrossRepoPrompt = { - link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + link: { + slug: { owner: string; repo: string; host?: string } + number: number + type: 'issue' | 'pr' + } matchingRepo: PasteRepoCandidate } @@ -73,7 +77,9 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { // the mode/repo changes so one provider's rows never render under another tab. const scopeRef = useRef('') const dismissedPasteRef = useRef('') - const repoSlugCacheRef = useRef>(new Map()) + const repoSlugCacheRef = useRef< + Map + >(new Map()) useEffect(() => { if (!client || !enabled || mode === 'text') { @@ -185,7 +191,7 @@ async function runSmartSearch(args: { linearWorkspaceId: string | null | undefined repos: readonly PasteRepoCandidate[] dismissedPasteRef: { current: string } - repoSlugCache: Map + repoSlugCache: Map }): Promise<{ fan: SmartFanOutResult paste: PasteResolved diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts index 0975dd325ae..8fa8d667dc5 100644 --- a/src/main/git/gh-rate-limit-breaker.test.ts +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + _getGhRateLimitBlockCount, _resetGhRateLimitBreaker, classifyGhRateLimitBucket, createGhRateLimitBlockedError, @@ -7,6 +8,7 @@ import { isGhPrimaryRateLimitStderr, isGhRateLimitProbe, notifyGhPrimaryRateLimit, + parseGhRateLimitScopeKey, recordGhPrimaryRateLimit, registerGhRateLimitResetProbe } from './gh-rate-limit-breaker' @@ -41,6 +43,7 @@ describe('isGhRateLimitProbe', () => { it('recognizes the exempt rate_limit endpoint only', () => { expect(isGhRateLimitProbe(['api', 'rate_limit'])).toBe(true) expect(isGhRateLimitProbe(['api', '/rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', '--hostname', 'github.com', 'rate_limit'])).toBe(true) expect(isGhRateLimitProbe(['api', 'search/issues?q=x'])).toBe(false) expect(isGhRateLimitProbe(['pr', 'list'])).toBe(false) }) @@ -58,6 +61,36 @@ describe('isGhPrimaryRateLimitStderr', () => { }) }) +describe('parseGhRateLimitScopeKey', () => { + it('parses native and wsl scopes, keeping ported hosts intact', () => { + expect(parseGhRateLimitScopeKey('native:github.com')).toEqual({ + runtime: 'native', + host: 'github.com' + }) + expect(parseGhRateLimitScopeKey('native:ghe.corp:8443')).toEqual({ + runtime: 'native', + host: 'ghe.corp:8443' + }) + expect(parseGhRateLimitScopeKey('wsl:ubuntu:github.com')).toEqual({ + runtime: 'wsl', + wslDistro: 'ubuntu', + host: 'github.com' + }) + expect(parseGhRateLimitScopeKey('wsl:ubuntu:ghe.corp:8443')).toEqual({ + runtime: 'wsl', + wslDistro: 'ubuntu', + host: 'ghe.corp:8443' + }) + }) + + it('rejects malformed scopes', () => { + expect(parseGhRateLimitScopeKey('native:')).toBeNull() + expect(parseGhRateLimitScopeKey('wsl:ubuntu')).toBeNull() + expect(parseGhRateLimitScopeKey('wsl:ubuntu:')).toBeNull() + expect(parseGhRateLimitScopeKey('ssh:github.com')).toBeNull() + }) +}) + describe('breaker state', () => { it('blocks until the recorded time, then expires', () => { const now = 1_000_000 @@ -76,14 +109,68 @@ describe('breaker state', () => { expect(getGhRateLimitBlockedUntilMs('core', now)).toBe(now + 60_000) }) + it('isolates blocks by host and execution runtime scope', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('core', now + 60_000, 'native:github.com') + + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:github.com')).toBe(now + 60_000) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:github.acme-corp.com')).toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', now, 'wsl:ubuntu:github.com')).toBeNull() + }) + + it('bounds retained blocks across user-supplied Enterprise host scopes', () => { + const now = Date.now() + for (let i = 0; i < 1_200; i += 1) { + recordGhPrimaryRateLimit('core', now + 60_000, `native:ghe-${i}.example.test`) + } + + expect(_getGhRateLimitBlockCount()).toBe(1024) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-1199.example.test')).toBe( + now + 60_000 + ) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-0.example.test')).toBeNull() + }) + + it('keeps a recently-read active scope when the bounded map evicts', () => { + const now = Date.now() + const hotScope = 'native:active.example.test' + recordGhPrimaryRateLimit('core', now + 60_000, hotScope) + for (let i = 0; i < 1_023; i += 1) { + recordGhPrimaryRateLimit('core', now + 60_000, `native:cold-${i}.example.test`) + } + + expect(getGhRateLimitBlockedUntilMs('core', now, hotScope)).toBe(now + 60_000) + recordGhPrimaryRateLimit('core', now + 60_000, 'native:new.example.test') + + expect(getGhRateLimitBlockedUntilMs('core', now, hotScope)).toBe(now + 60_000) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:cold-0.example.test')).toBeNull() + }) + it('notifyGhPrimaryRateLimit applies a fallback block and fires the reset probe', () => { const probe = vi.fn() registerGhRateLimitResetProbe(probe) notifyGhPrimaryRateLimit('search') - expect(probe).toHaveBeenCalledWith('search') + expect(probe).toHaveBeenCalledWith('search', 'native:github.com') expect(getGhRateLimitBlockedUntilMs('search')).toBeGreaterThan(Date.now()) }) + it('fires the reset probe with the tripping scope for non-default scopes', () => { + // Why: a WSL-only Windows install (or GHES) would otherwise stay on the + // blunt 5-minute fallback forever — the probe must learn which scope to + // query. + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + + notifyGhPrimaryRateLimit('core', 'native:github.acme-corp.com') + notifyGhPrimaryRateLimit('core', 'wsl:ubuntu:github.com') + + expect(probe).toHaveBeenNthCalledWith(1, 'core', 'native:github.acme-corp.com') + expect(probe).toHaveBeenNthCalledWith(2, 'core', 'wsl:ubuntu:github.com') + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).not.toBeNull() + }) + it('creates an error that classifies as rate_limited, not permission_denied', () => { const error = createGhRateLimitBlockedError('search', Date.now() + 30_000) expect(error.stderr.toLowerCase()).toContain('rate limit') diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index 94bef46ff7a..8bc4276490a 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -25,8 +25,45 @@ const FALLBACK_BLOCK_MS: Record = { graphql: 5 * 60_000 } -const blockedUntilMsByBucket = new Map() -let resetProbe: ((bucket: GhRateLimitBucket) => void) | null = null +// Why: the runner derives scopes with this builder too — a format drift would +// silently split blocks from the default-scope reads below. +export function ghRateLimitScopeKey(runtime: string, host: string): string { + return `${runtime}:${host.toLowerCase()}` +} + +export type GhRateLimitScopeParts = + | { runtime: 'native'; host: string } + | { runtime: 'wsl'; wslDistro: string; host: string } + +// Why: the reset probe must spawn gh against the tripping scope's runtime and +// host. Parsing lives next to the builder so the key format stays private to +// this module instead of being string-split at call sites. +export function parseGhRateLimitScopeKey(scope: string): GhRateLimitScopeParts | null { + if (scope.startsWith('native:')) { + const host = scope.slice('native:'.length) + return host ? { runtime: 'native', host } : null + } + if (scope.startsWith('wsl:')) { + // Hosts may carry a port (host:port), so only the first colon after the + // distro is a separator. + const rest = scope.slice('wsl:'.length) + const sep = rest.indexOf(':') + if (sep <= 0 || sep === rest.length - 1) { + return null + } + return { runtime: 'wsl', wslDistro: rest.slice(0, sep), host: rest.slice(sep + 1) } + } + return null +} + +const DEFAULT_SCOPE = ghRateLimitScopeKey('native', 'github.com') +const GH_RATE_LIMIT_BLOCK_MAX_ENTRIES = 1024 +const blockedUntilMsByScopeAndBucket = new Map() +let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null + +function breakerKey(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): string { + return `${scope}\0${bucket}` +} // gh api flags that take a separate value, so the endpoint arg can be found. const GH_API_VALUE_FLAGS = new Set([ @@ -103,43 +140,77 @@ export function isGhPrimaryRateLimitStderr(stderr: string): boolean { return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') } -export function recordGhPrimaryRateLimit(bucket: GhRateLimitBucket, blockedUntilMs: number): void { - const existing = blockedUntilMsByBucket.get(bucket) ?? 0 - blockedUntilMsByBucket.set(bucket, Math.max(existing, blockedUntilMs)) +export function recordGhPrimaryRateLimit( + bucket: GhRateLimitBucket, + blockedUntilMs: number, + scope = DEFAULT_SCOPE +): void { + const key = breakerKey(bucket, scope) + const existing = blockedUntilMsByScopeAndBucket.get(key) ?? 0 + // Why: host scopes can originate from pasted GHES URLs. Refresh insertion + // order and cap retained blocks so a stream of failing hosts cannot grow the + // main-process map for the lifetime of the app. + blockedUntilMsByScopeAndBucket.delete(key) + blockedUntilMsByScopeAndBucket.set(key, Math.max(existing, blockedUntilMs)) + while (blockedUntilMsByScopeAndBucket.size > GH_RATE_LIMIT_BLOCK_MAX_ENTRIES) { + const oldestKey = blockedUntilMsByScopeAndBucket.keys().next().value + if (oldestKey === undefined) { + break + } + blockedUntilMsByScopeAndBucket.delete(oldestKey) + } } -export function clearGhRateLimitBlock(bucket: GhRateLimitBucket): void { - blockedUntilMsByBucket.delete(bucket) +export function clearGhRateLimitBlock(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { + blockedUntilMsByScopeAndBucket.delete(breakerKey(bucket, scope)) } export function getGhRateLimitBlockedUntilMs( bucket: GhRateLimitBucket, - nowMs: number = Date.now() + nowMs: number = Date.now(), + scope = DEFAULT_SCOPE ): number | null { - const blockedUntil = blockedUntilMsByBucket.get(bucket) + for (const [key, blockedUntil] of blockedUntilMsByScopeAndBucket) { + if (blockedUntil <= nowMs) { + blockedUntilMsByScopeAndBucket.delete(key) + } + } + const key = breakerKey(bucket, scope) + const blockedUntil = blockedUntilMsByScopeAndBucket.get(key) if (blockedUntil === undefined) { return null } if (blockedUntil <= nowMs) { - blockedUntilMsByBucket.delete(bucket) + blockedUntilMsByScopeAndBucket.delete(key) return null } + // Why: active blocks are hot entries; refresh their insertion order so a + // stream of one-off hostile hosts evicts cold scopes before an active one. + blockedUntilMsByScopeAndBucket.delete(key) + blockedUntilMsByScopeAndBucket.set(key, blockedUntil) return blockedUntil } +/** @internal — cache-bound tests only. */ +export function _getGhRateLimitBlockCount(): number { + return blockedUntilMsByScopeAndBucket.size +} + /** Register the (single) callback that refreshes precise reset times after a * breaker trip. Registered by the github rate-limit prober at module load. */ export function registerGhRateLimitResetProbe( - probe: ((bucket: GhRateLimitBucket) => void) | null + probe: ((bucket: GhRateLimitBucket, scope: string) => void) | null ): void { resetProbe = probe } /** Called by the runner when a gh spawn came back with a primary 403. */ -export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket): void { - recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket]) +export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { + recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket], scope) + // The probe receives the tripping scope so it can run gh against that + // runtime/host and refine the fallback block into the real reset time. try { - resetProbe?.(bucket) + resetProbe?.(bucket, scope) } catch { // best-effort refinement; the fallback block stands } @@ -161,6 +232,6 @@ export function createGhRateLimitBlockedError( /** @internal — test-only */ export function _resetGhRateLimitBreaker(): void { - blockedUntilMsByBucket.clear() + blockedUntilMsByScopeAndBucket.clear() resetProbe = null } diff --git a/src/main/git/runner-gh-host-args.test.ts b/src/main/git/runner-gh-host-args.test.ts new file mode 100644 index 00000000000..0bee44eac74 --- /dev/null +++ b/src/main/git/runner-gh-host-args.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import { applyGhHostToArgs } from './runner' + +describe('applyGhHostToArgs', () => { + it('returns args unchanged when no host is given', () => { + const args = ['api', 'repos/a/b/pulls'] + expect(applyGhHostToArgs(args)).toBe(args) + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'])).toEqual([ + 'pr', + 'list', + '--repo', + 'a/b' + ]) + }) + + it('injects --hostname right after `api` for any host', () => { + expect(applyGhHostToArgs(['api', 'rate_limit'], 'github.com')).toEqual([ + 'api', + '--hostname', + 'github.com', + 'rate_limit' + ]) + expect(applyGhHostToArgs(['api', 'repos/a/b/pulls'], 'github.acme-corp.com')).toEqual([ + 'api', + '--hostname', + 'github.acme-corp.com', + 'repos/a/b/pulls' + ]) + }) + + it('does not double-inject when --hostname is already present', () => { + expect( + applyGhHostToArgs(['api', '--hostname', 'github.com', 'rate_limit'], 'github.acme-corp.com') + ).toEqual(['api', '--hostname', 'github.com', 'rate_limit']) + expect( + applyGhHostToArgs(['api', '--hostname=github.com', 'rate_limit'], 'github.acme-corp.com') + ).toEqual(['api', '--hostname=github.com', 'rate_limit']) + }) + + it('qualifies bare owner/repo in --repo / -R for a GHES host', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'github.acme-corp.com/a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '-R', 'a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-R', + 'github.acme-corp.com/a/b' + ]) + }) + + it('qualifies the inline --repo= form for a GHES host', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo=a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '--repo=github.acme-corp.com/a/b' + ]) + }) + + it('leaves combined -R short forms untouched (indistinguishable from free-text values)', () => { + expect(applyGhHostToArgs(['pr', 'list', '-R=a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-R=a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '-Ra/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-Ra/b' + ]) + }) + + it('does not rewrite free-text flag values that merely start with -R', () => { + expect( + applyGhHostToArgs( + ['pr', 'edit', '7', '--title', '-Refactor foo/bar', '--repo', 'a/b'], + 'github.acme-corp.com' + ) + ).toEqual([ + 'pr', + 'edit', + '7', + '--title', + '-Refactor foo/bar', + '--repo', + 'github.acme-corp.com/a/b' + ]) + }) + + it('qualifies --repo for the github.com host so GH_HOST cannot redirect it', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'github.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'github.com/a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'GitHub.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'GitHub.com/a/b' + ]) + }) + + it('passes through URL and already-qualified 3-part --repo values', () => { + expect( + applyGhHostToArgs( + ['pr', 'list', '--repo', 'https://github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '--repo', 'https://github.acme-corp.com/a/b']) + expect( + applyGhHostToArgs( + ['pr', 'list', '--repo', 'github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '--repo', 'github.acme-corp.com/a/b']) + expect( + applyGhHostToArgs( + ['pr', 'list', '-R', 'https://github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '-R', 'https://github.acme-corp.com/a/b']) + }) + + it('injects --hostname and qualifies --repo together for GHES api calls', () => { + expect( + applyGhHostToArgs(['api', 'repos/a/b/pulls', '--repo', 'a/b'], 'github.acme-corp.com') + ).toEqual([ + 'api', + '--hostname', + 'github.acme-corp.com', + 'repos/a/b/pulls', + '--repo', + 'github.acme-corp.com/a/b' + ]) + }) +}) diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index dc22bb54f4e..17e14b8e7d6 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -13,7 +13,11 @@ vi.mock('child_process', () => ({ })) import { ghExecFileAsync } from './runner' -import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' +import { + _resetGhRateLimitBreaker, + getGhRateLimitBlockedUntilMs, + registerGhRateLimitResetProbe +} from './gh-rate-limit-breaker' const PRIMARY_RATE_LIMIT_STDERR = 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' @@ -71,11 +75,124 @@ describe('ghExecFileAsync rate-limit breaker', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('keeps other GitHub hosts and WSL runtimes working when github.com is blocked', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() + + // The breaker scope now comes from options.host / GH_HOST / wslDistro, not + // from sniffing argv — so an Enterprise host must be routed via options.host. + mockGhSuccess('[]') + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { host: 'github.acme-corp.com' }) + ).resolves.toMatchObject({ stdout: '[]' }) + await expect( + ghExecFileAsync(['pr', 'list', '--repo', 'a/b'], { host: 'github.acme-corp.com' }) + ).resolves.toMatchObject({ stdout: '[]' }) + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { + env: { ...process.env, GH_HOST: 'github.acme-corp.com' } + }) + ).resolves.toMatchObject({ stdout: '[]' }) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ stdout: '[]' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + expect(execFileMock).toHaveBeenCalledTimes(5) + }) + + it.each([ + ['separate', ['--hostname', 'github.com']], + ['inline', ['--hostname=github.com']] + ])('scopes an explicit %s hostname ahead of process GH_HOST', async (_name, hostnameArgs) => { + const originalGhHost = process.env.GH_HOST + process.env.GH_HOST = 'github.acme-corp.com' + try { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', ...hostnameArgs, 'repos/a/b/pulls'])).rejects.toThrow( + 'rate limit' + ) + + expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).not.toBeNull() + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).toBeNull() + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + } finally { + if (originalGhHost === undefined) { + delete process.env.GH_HOST + } else { + process.env.GH_HOST = originalGhHost + } + } + }) + + it.each([ + ['--repo', ['pr', 'list', '--repo', 'github.acme-corp.com/acme/widgets']], + ['-R', ['pr', 'list', '-R', 'github.acme-corp.com/acme/widgets']], + ['--repo=', ['pr', 'list', '--repo=github.acme-corp.com/acme/widgets']] + ])('scopes a pre-qualified %s value to its repository host', async (_name, args) => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + + await expect( + ghExecFileAsync(args, { env: { ...process.env, GH_HOST: 'github.com' } }) + ).rejects.toThrow('rate limit') + + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).toBeNull() + }) + + it('scopes a WSL UNC-cwd 403 to the distro runtime and probes that scope', async () => { + // Why: a \\wsl.localhost\... cwd routes gh through wsl.exe, so the 403 + // belongs to the distro's account — the native scope must stay usable and + // the reset probe must be told which scope tripped. + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + }) + ).rejects.toThrow() + + expect(probe).toHaveBeenCalledWith('core', 'wsl:ubuntu:github.com') + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'wsl:ubuntu:github.com') + ).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', Date.now())).toBeNull() + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('never blocks the exempt rate_limit probe', async () => { mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() mockGhSuccess('{"resources":{}}') - await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toMatchObject({ + await expect( + ghExecFileAsync(['api', '--hostname', 'github.com', 'rate_limit']) + ).resolves.toMatchObject({ stdout: '{"resources":{}}' }) expect(execFileMock).toHaveBeenCalledTimes(2) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 8b802c2f0d6..4cd81cda230 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -21,6 +21,10 @@ vi.mock('../wsl', async (importOriginal) => ({ })) import { ghExecFileAsync, glabExecFileAsync } from './runner' +import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' + +const PRIMARY_RATE_LIMIT_STDERR = + 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' type MockChildProcess = EventEmitter & { pid: number @@ -44,6 +48,7 @@ describe('ghExecFileAsync WSL fallback', () => { spawnMock.mockReset() getDefaultWslDistroMock.mockReset() getDefaultWslDistroMock.mockReturnValue(null) + _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' @@ -52,6 +57,7 @@ describe('ghExecFileAsync WSL fallback', () => { afterEach(() => { vi.useRealTimers() + _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -187,6 +193,42 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('falls back for repo view with an explicit positional repository', async () => { + execFileMock.mockImplementation((binary, _args, options, callback) => { + if (typeof options === 'function') { + callback = options + } + if (binary === 'wsl.exe') { + callback( + Object.assign(new Error('Command failed: wsl.exe'), { + stdout: '', + stderr: 'bash: line 1: gh: command not found\n' + }) + ) + return + } + callback(null, { stdout: '{"isFork":false}', stderr: '' }) + }) + + await expect( + ghExecFileAsync( + ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`, + host: 'github.acme-corp.com' + } + ) + ).resolves.toEqual({ stdout: '{"isFork":false}', stderr: '' }) + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'gh', + ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('does not fall back for gh api calls that depend on repo-context placeholders', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -227,6 +269,43 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('retries a host-pinned idempotent gh GraphQL query after host injection', async () => { + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback( + Object.assign(new Error('HTTP 502 Bad Gateway'), { + stdout: '', + stderr: 'HTTP 502 Bad Gateway' + }) + ) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: '{"data":{}}', stderr: '' }) + }) + + await expect( + ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }'], { + host: 'github.acme-corp.com' + }) + ).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' }) + + expect(execFileMock).toHaveBeenCalledTimes(2) + expect(execFileMock).toHaveBeenNthCalledWith( + 1, + 'gh', + [ + 'api', + '--hostname', + 'github.acme-corp.com', + 'graphql', + '-f', + 'query=query { viewer { login } }' + ], + expect.any(Object), + expect.any(Function) + ) + }) + it('does not retry non-idempotent gh API transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -307,6 +386,63 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('checks a blocked WSL scope before repeating a native-to-WSL fallback', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock.mockImplementation((binary, _args, _options, callback) => { + if (binary === 'gh') { + callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT', stderr: '' })) + return + } + callback( + Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), { + stdout: '', + stderr: PRIMARY_RATE_LIMIT_STDERR + }) + ) + }) + + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toThrow('rate limit') + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toMatchObject({ + ghRateLimitBlocked: true + }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['gh', 'wsl.exe', 'gh']) + }) + + it('checks a blocked native scope before repeating a WSL-to-native fallback', async () => { + execFileMock.mockImplementation((binary, _args, _options, callback) => { + if (binary === 'wsl.exe') { + callback( + Object.assign(new Error('Command failed: wsl.exe'), { + stdout: '', + stderr: 'bash: line 1: gh: command not found\n' + }) + ) + return + } + callback( + Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), { + stdout: '', + stderr: PRIMARY_RATE_LIMIT_STDERR + }) + ) + }) + + const options = { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa` + } + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options)).rejects.toThrow( + 'rate limit' + ) + await expect( + ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['wsl.exe', 'gh', 'wsl.exe']) + }) + it('does not retry non-idempotent glab transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index a4b51ff0624..ae4e93174e1 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -21,9 +21,11 @@ import { classifyGhRateLimitBucket, createGhRateLimitBlockedError, getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, isGhPrimaryRateLimitStderr, isGhRateLimitProbe, - notifyGhPrimaryRateLimit + notifyGhPrimaryRateLimit, + type GhRateLimitBucket } from './gh-rate-limit-breaker' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { addWslEnvKeys } from '../wsl-env' @@ -111,6 +113,17 @@ function argsUseGhApiPlaceholders(args: string[]): boolean { ) } +function hasExplicitRepoViewTarget(args: string[]): boolean { + const target = args[2] + return ( + args[0] === 'repo' && + args[1] === 'view' && + typeof target === 'string' && + !target.startsWith('-') && + target.includes('/') + ) +} + function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { if (hasExplicitRepoArg(args)) { return true @@ -118,7 +131,7 @@ function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { if (args[0] === 'api') { return !argsUseGhApiPlaceholders(args) } - return args[0] === 'auth' + return args[0] === 'auth' || hasExplicitRepoViewTarget(args) } function isMissingCommandInWsl(stderr: string, command: string): boolean { @@ -1120,6 +1133,11 @@ type GhExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's + // default host, not the repo's remote. Carrying the host here lets the + // runner qualify every spawn once, so call sites can't silently fall back + // to github.com for GHES repos; it also scopes the rate-limit breaker. + host?: string } const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) @@ -1257,6 +1275,128 @@ function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.Proce } } +function hasGhHostnameFlag(args: readonly string[]): boolean { + return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) +} + +function hostQualifiedGhRepoValue(value: string, host: string): string { + // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. + if (value.includes('://') || value.split('/').length !== 2) { + return value + } + return `${host}/${value}` +} + +/** + * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` + * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run + * gh with no cwd, so this is their only host signal (#8312). + * + * @internal exported for tests. + */ +export function applyGhHostToArgs(args: string[], host?: string): string[] { + if (!host) { + return args + } + let result = args + if (result[0] === 'api' && !hasGhHostnameFlag(result)) { + result = ['api', '--hostname', host, ...result.slice(1)] + } + // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST + // when set — so github.com must be qualified too, not just GHES, or a + // process-level GH_HOST redirects pinned github.com commands. + // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: + // no call site uses them, and prefix-matching `-R` corrupts free-text values + // of other flags (e.g. a --title that happens to start with `-R`). + const qualified: string[] = [] + for (let i = 0; i < result.length; i += 1) { + const arg = result[i] + if (arg === '--repo' || arg === '-R') { + qualified.push(arg) + const value = result[i + 1] + if (value !== undefined) { + qualified.push(hostQualifiedGhRepoValue(value, host)) + i += 1 + } + continue + } + if (arg.startsWith('--repo=')) { + qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) + continue + } + qualified.push(arg) + } + return qualified +} + +function explicitGhHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--hostname') { + const value = args[i + 1]?.trim() + return value || undefined + } + if (args[i].startsWith('--hostname=')) { + const value = args[i].slice('--hostname='.length).trim() + return value || undefined + } + } + return undefined +} + +function explicitGhRepoHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + let value: string | undefined + if (args[i] === '--repo' || args[i] === '-R') { + value = args[i + 1] + } else if (args[i].startsWith('--repo=')) { + value = args[i].slice('--repo='.length) + } + const parts = value?.trim().split('/') + if (parts?.length === 3 && parts.every(Boolean)) { + return parts[0] + } + } + return undefined +} + +function ghRateLimitScope( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand +): string { + const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' + // Why: an explicit argv hostname controls the actual gh request even when + // GH_HOST or options.host disagree, so breaker state must follow that host. + const host = + explicitGhHostname(args) ?? + options.host ?? + explicitGhRepoHostname(args) ?? + options.env?.GH_HOST ?? + process.env.GH_HOST ?? + 'github.com' + return ghRateLimitScopeKey(runtime, host) +} + +function assertGhRateLimitScopeAvailable( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand, + bucket: GhRateLimitBucket, + exemptProbe: boolean +): void { + if (exemptProbe) { + return + } + const blockedUntilMs = getGhRateLimitBlockedUntilMs( + bucket, + Date.now(), + ghRateLimitScope(args, options, resolved) + ) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(bucket, blockedUntilMs) + } +} + /** * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. @@ -1268,15 +1408,15 @@ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. - const rateLimitBucket = classifyGhRateLimitBucket(args) - if (!isGhRateLimitProbe(args)) { - const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket) - if (blockedUntilMs !== null) { - throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs) - } - } + // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + args = applyGhHostToArgs(args, options.host) let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. + // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. + const rateLimitBucket = classifyGhRateLimitBucket(args) + const rateLimitProbe = isGhRateLimitProbe(args) + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) let lastError: unknown let attemptedHostFallback = false let attemptedDefaultWslFallback = false @@ -1295,7 +1435,7 @@ export async function ghExecFileAsync( lastError = err const { stderr } = extractExecError(err) if (isGhPrimaryRateLimitStderr(stderr)) { - notifyGhPrimaryRateLimit(rateLimitBucket) + notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) } if ( process.platform === 'win32' && @@ -1310,6 +1450,7 @@ export async function ghExecFileAsync( // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. resolved = wslResolved attemptedDefaultWslFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) attempt = -1 continue } @@ -1317,12 +1458,11 @@ export async function ghExecFileAsync( if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { resolved = resolveHostGitHubCli('gh', args) attemptedHostFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) attempt = -1 continue } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // Why: only retry idempotent calls; a transient error can arrive after a write already applied, so retrying would duplicate it. - const idempotent = options.idempotent ?? argsLookIdempotent(args) if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. const retryAfterMs = parseRetryAfterMs(stderr) diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts index 35e3775b478..7735910dc83 100644 --- a/src/main/github/auth-diagnose.test.ts +++ b/src/main/github/auth-diagnose.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { parseAuthStatus } from './auth-diagnose' +import { describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn() })) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) +import { diagnoseGhAuth, parseAuthStatus } from './auth-diagnose' describe('parseAuthStatus', () => { it('parses an env-shadowed login alongside a keyring login (real gh output)', () => { @@ -82,6 +88,18 @@ ghe.acme.io expect(accounts[1].scopes).toContain('project') }) + it('parses a ported GHES auth-status section', () => { + const text = `ghe.acme.io:8443 + ✓ Logged in to ghe.acme.io:8443 account bob (keyring) + - Active account: true + - Token scopes: 'project', 'repo' +` + + expect(parseAuthStatus(text)).toEqual([ + expect.objectContaining({ host: 'ghe.acme.io:8443', user: 'bob', active: true }) + ]) + }) + it('recovers host from the Logged-in line when the section header is missing', () => { // gh prints a colon after the host on some versions; we tolerate it, // but if the regex ever fails to match the header we still want @@ -95,3 +113,48 @@ ghe.acme.io expect(accounts[0].host).toBe('github.acme.io') }) }) + +describe('diagnoseGhAuth host scoping', () => { + const TWO_HOST_STATUS = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'project', 'read:org', 'repo' +ghe.acme.io + ✓ Logged in to ghe.acme.io account bob (keyring) + - Active account: true + - Token scopes: 'repo' +` + + it('reports an unauthenticated required host and keeps scope advice host-scoped', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth('github.other-corp.com') + + expect(diag.requiredHost).toBe('github.other-corp.com') + expect(diag.requiredHostAuthenticated).toBe(false) + expect(diag.activeAccount).toBeNull() + }) + + it("scopes the diagnosis to the required host's account", async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth('GHE.Acme.IO') + + expect(diag.requiredHost).toBe('ghe.acme.io') + expect(diag.requiredHostAuthenticated).toBe(true) + expect(diag.activeAccount?.user).toBe('bob') + // Why: bob's GHES token lacks project/read:org even though alice's + // github.com token has them — advice must reflect the required host. + expect(diag.missingScopes).toEqual(['project', 'read:org']) + }) + + it('keeps legacy host-less behavior byte-identical', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth() + + expect(diag.requiredHost).toBeNull() + expect(diag.requiredHostAuthenticated).toBeNull() + expect(diag.activeAccount?.user).toBe('alice') + }) +}) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index aeaf4b9e284..8051af7f8b4 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -39,7 +39,7 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { // (internal GHES like `github` or `ghe-internal`); we also recover the // host from the `Logged in to ` line below if this header was // missed, so a parser miss never silently drops every account. - const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i) + const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*(?::\d+)?)\s*:?\s*$/i) if (hostMatch && !/^logged\b/i.test(line)) { currentHost = hostMatch[1] continue @@ -90,7 +90,7 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { return accounts } -export async function diagnoseGhAuth(): Promise { +export async function diagnoseGhAuth(requiredHost?: string): Promise { let raw = '' let ghAvailable = true try { @@ -118,7 +118,16 @@ export async function diagnoseGhAuth(): Promise { } } const accounts = parseAuthStatus(raw) - const active = accounts.find((a) => a.active) ?? accounts[0] ?? null + // Why: when the caller names a host (a GHES origin), scope the diagnosis to + // that host's account — the github.com account's scopes are irrelevant to it. + const normalizedRequiredHost = requiredHost?.trim().toLowerCase() || null + const hostAccounts = normalizedRequiredHost + ? accounts.filter((a) => a.host.toLowerCase() === normalizedRequiredHost) + : accounts + const active = + hostAccounts.find((a) => a.active) ?? + hostAccounts[0] ?? + (normalizedRequiredHost ? null : (accounts.find((a) => a.active) ?? accounts[0] ?? null)) const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN ? 'GH_TOKEN' : process.env.GITHUB_TOKEN @@ -141,6 +150,8 @@ export async function diagnoseGhAuth(): Promise { envTokenInProcess, missingScopes, requiredScopes: [...REQUIRED_SCOPES], - hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active) + hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active), + requiredHost: normalizedRequiredHost, + requiredHostAuthenticated: normalizedRequiredHost ? hostAccounts.length > 0 : null } } diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 34b0d801aeb..c3efb986bad 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -57,11 +57,20 @@ vi.mock('../git/runner', () => ({ })) vi.mock('./github-enterprise-repository', () => ({ - getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) import { createGitHubPullRequest } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('createGitHubPullRequest', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -167,7 +176,7 @@ describe('createGitHubPullRequest', () => { expect(args[args.indexOf('--head') + 1]).toBe('my-branch') }) - it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => { + it('routes --repo to the Enterprise server via options.host for a GHES remote (#8312)', async () => { // github.com-only slug parsing misses GHES, so creation comes from the // enterprise resolver, which carries the host. getOwnerRepoMock.mockResolvedValueOnce(null) @@ -194,13 +203,14 @@ describe('createGitHubPullRequest', () => { url: 'https://github.acme-corp.com/team/orca/pull/7' }) - const [args] = ghExecFileAsyncMock.mock.calls[0] - // Bare "team/orca" would resolve against gh's default host (github.com); - // the host prefix pins the command to the Enterprise server. - expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + const [args, options] = ghExecFileAsyncMock.mock.calls[0] + // The runner host-qualifies argv at spawn time from options.host, so the + // mocked call sees a bare owner/repo plus the host in exec options. + expect(args[args.indexOf('--repo') + 1]).toBe('team/orca') + expect(options).toMatchObject({ host: 'github.acme-corp.com' }) }) - it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => { + it('routes the GHES existing-PR fallback lookup through options.host (#8312)', async () => { getOwnerRepoMock.mockResolvedValue(null) getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'team', @@ -234,9 +244,10 @@ describe('createGitHubPullRequest', () => { existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } }) - const [listArgs] = ghExecFileAsyncMock.mock.calls[1] + const [listArgs, listOptions] = ghExecFileAsyncMock.mock.calls[1] expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) - expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('team/orca') + expect(listOptions).toMatchObject({ host: 'github.acme-corp.com' }) }) it('runs local WSL project pull request creation through the selected distro', async () => { @@ -301,7 +312,12 @@ describe('createGitHubPullRequest', () => { url: 'https://github.com/acme/widgets/pull/45' }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'origin', 'ssh-1') + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/remote/repo-root', + 'origin', + 'ssh-1', + {} + ) const [args, options] = ghExecFileAsyncMock.mock.calls[0] expect(args).toEqual( expect.arrayContaining([ @@ -459,7 +475,9 @@ describe('createGitHubPullRequest', () => { '--json', 'number,url' ], - { cwd: '/repo-root' } + // Why: dotcom slugs resolve with host:'github.com' so creation stays + // pinned against a process-level GH_HOST. + { cwd: '/repo-root', host: 'github.com' } ]) }) diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index c4345ffb6b6..a52a479e9f5 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -1,7 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { ghExecFileAsyncMock, acquireMock, releaseMock } = vi.hoisted(() => ({ +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -10,9 +18,18 @@ vi.mock('./gh-utils', () => ({ execFileAsync: vi.fn(), ghExecFileAsync: ghExecFileAsyncMock, gitExecFileAsync: vi.fn(), - getOwnerRepo: vi.fn(), + getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), - getOwnerRepoForRemote: vi.fn(), + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), resolveIssueSource: vi.fn(), ghRepoExecOptions: vi.fn((context) => context.connectionId @@ -31,11 +48,28 @@ vi.mock('./gh-utils', () => ({ _resetOwnerRepoCache: vi.fn() })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + import { setPRFileViewed } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('setPRFileViewed', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -59,7 +93,10 @@ describe('setPRFileViewed', () => { expect(args.find((arg: string) => arg.startsWith('query='))).toContain('markFileAsViewed') expect(args).toContain('pullRequestId=PR_kwDO123') expect(args).toContain('path=src/app.ts') - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ + cwd: '/repo-root', + host: 'github.com' + }) expect(releaseMock).toHaveBeenCalledTimes(1) }) @@ -95,7 +132,54 @@ describe('setPRFileViewed', () => { expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root', + host: 'github.com', wslDistro: 'Ubuntu' }) }) + + it('routes SSH-backed GitHub Enterprise file-viewed mutations to the Enterprise host', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + setPRFileViewed({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + pullRequestId: 'PR_enterprise', + path: 'src/enterprise.ts', + viewed: true + }) + ).resolves.toBe(true) + + // The runner injects --hostname at spawn time from options.host, so the + // mocked call sees unqualified argv plus the Enterprise host in options. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['api', 'graphql', 'pullRequestId=PR_enterprise']), + { host: 'github.acme-corp.com' } + ) + const [args] = ghExecFileAsyncMock.mock.calls[0] + expect(args).not.toContain('--hostname') + }) + + it('refuses an unresolved local file-viewed mutation', async () => { + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect( + setPRFileViewed({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca' }, + pullRequestId: 'PR_unresolved', + path: 'src/app.ts', + viewed: true + }) + ).resolves.toBe(false) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index a828a7759f7..5b867489797 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' const { @@ -7,6 +8,7 @@ const { getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, resolveIssueSourceMock, rateLimitGuardMock, noteRateLimitSpendMock, @@ -18,6 +20,7 @@ const { getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), + resolvePRRepositoryCandidatesMock: vi.fn(), resolveIssueSourceMock: vi.fn(), rateLimitGuardMock: vi.fn(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), @@ -44,9 +47,48 @@ vi.mock('./gh-utils', async () => { vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions), + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions) + } +}) + import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' const PR_LIST_FIELDS = @@ -107,6 +149,7 @@ describe('GitHub issue source split', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + resolvePRRepositoryCandidatesMock.mockReset() resolveIssueSourceMock.mockReset() rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) @@ -123,15 +166,17 @@ describe('GitHub issue source split', () => { source: await getIssueOwnerRepoMock(), fellBack: false })) - // Why: since #7331 `resolvePrWorkItemSource` fetches origin through - // `getOwnerRepoForRemote` (getOwnerRepo became upstream-first). Route the - // origin probe to `getOwnerRepoMock` so existing tests keep defining the - // origin candidate through it; default upstream to null. Tests that care - // about upstream override with their own implementation. + // Why: keep origin on the legacy mock while hosted candidate tests opt in + // to upstream behavior explicitly. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null ) + resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { + const origin = await getOwnerRepoMock(repoPath, connectionId) + const repository = origin ? { host: 'github.com', ...origin } : null + return { candidates: repository ? [repository] : [], headRepo: repository } + }) _resetOwnerRepoCache() }) @@ -426,11 +471,80 @@ describe('GitHub issue source split', () => { '--json', expect.stringContaining('reviewDecision') ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(item?.type).toBe('pr') }) + it('probes the upstream repository for a typed fork PR before origin', async () => { + const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Upstream PR', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/42', + labels: [], + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' }, + isDraft: false + }) + }) + + const item = await getWorkItem('/repo-root', 42, 'pr') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '42', + '--repo', + 'stablyai/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root', host: 'github.com' } + ) + expect(item?.prRepo).toEqual(upstream) + }) + + it('does not run a bare gh lookup for an SSH repo without candidates', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) + + await expect(getWorkItem('/remote/repo', 42, 'pr', 'ssh-1')).resolves.toBeNull() + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not probe a second PR repository after a non-not-found failure', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ + { owner: 'stablyai', repo: 'orca', host: 'github.com' }, + { owner: 'fork', repo: 'orca', host: 'github.com' } + ], + headRepo: { owner: 'fork', repo: 'orca', host: 'github.com' } + }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toBeNull() + + // The first candidate uses `pr view` and its REST compatibility fallback; + // neither failure is a 404, so a same-number PR on origin is never queried. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => + (args as string[]).some((arg) => arg.includes('fork/orca')) + ) + ).toBe(false) + }) + it('raw number lookup tries upstream issue before origin PR', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) // Why: simulate a real gh 404 (the only error type that should fall through). @@ -469,7 +583,7 @@ describe('GitHub issue source split', () => { '--json', expect.stringContaining('reviewDecision') ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(item?.type).toBe('pr') }) diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 429969b52f1..b092b01eb94 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), @@ -16,6 +17,16 @@ vi.mock('./gh-utils', () => ({ ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), extractExecError: vi.fn((err: unknown) => ({ stderr: String(err), stdout: '' })), acquire: vi.fn(), release: vi.fn(), @@ -26,9 +37,17 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: vi.fn() + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: vi.fn(() => true) })) import { getPRCheckDetails, _resetOwnerRepoCache } from './client' @@ -129,17 +148,17 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/check-runs/88'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -206,8 +225,64 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, ['api', 'repos/acme/widgets/actions/jobs/8801/logs'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) }) + + it('isolates failed-job log tails for the same job ID on different GitHub hosts', async () => { + ghExecFileAsyncMock.mockImplementation(async (args: string[], options?: { host?: string }) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + const enterprise = options?.host === 'github.acme-corp.com' + if (endpoint.endsWith('/check-runs/99')) { + return { + stdout: JSON.stringify({ + name: 'verify', + status: 'completed', + conclusion: 'failure', + check_suite: { workflow_run: { id: 77 } } + }) + } + } + if (endpoint.endsWith('/check-runs/99/annotations?per_page=20')) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/actions/runs/77/jobs?per_page=100')) { + return { + stdout: JSON.stringify({ + jobs: [ + { + id: 9901, + name: 'verify', + status: 'completed', + conclusion: 'failure', + steps: [] + } + ] + }) + } + } + if (endpoint.endsWith('/actions/jobs/9901/logs')) { + return { stdout: enterprise ? 'enterprise log' : 'github.com log' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const githubDotCom = await getPRCheckDetails('/repo-root', { + checkRunId: 99, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + const enterprise = await getPRCheckDetails('/repo-root', { + checkRunId: 99, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + }) + + expect(githubDotCom?.jobs[0]?.logTail).toBe('github.com log') + expect(enterprise?.jobs[0]?.logTail).toBe('enterprise log') + const logCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => + args.some((arg) => arg.endsWith('/actions/jobs/9901/logs')) + ) + expect(logCalls).toHaveLength(2) + expect(logCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) + }) }) diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 151bd94fef6..72ca0f77cff 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -8,8 +8,9 @@ const { gitExecFileAsyncMock, extractExecErrorMock, getRateLimitMock, - rateLimitGuardMock, - noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuotaMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -29,8 +30,11 @@ const { return { stderr: String(err), stdout: '' } }), getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn< + (repository?: { host?: string } | null, options?: { wslDistro?: string }) => boolean + >(() => true), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -53,6 +57,16 @@ vi.mock('./gh-utils', () => ({ }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), extractExecError: extractExecErrorMock, acquire: acquireMock, release: releaseMock, @@ -65,12 +79,21 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) import { getPRChecks, rerunPRChecks, _resetOwnerRepoCache } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function graphQLChecksResponse({ contexts = [], checkSuites = [], @@ -194,9 +217,14 @@ describe('getPRChecks', () => { extractExecErrorMock.mockClear() getRateLimitMock.mockReset() getRateLimitMock.mockResolvedValue({ resources: {} }) - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockImplementation( + (repository?: { host?: string } | null, options?: { wslDistro?: string }) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && !options?.wslDistro + ) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -413,12 +441,12 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(checks).toEqual([ { @@ -496,7 +524,7 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(checks).toEqual([ { @@ -533,7 +561,11 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], - { cwd: '/repo-root', env: { ...process.env, GH_PROMPT_DISABLED: '1' } } + { + cwd: '/repo-root', + env: { ...process.env, GH_PROMPT_DISABLED: '1' }, + host: 'github.com' + } ) }) @@ -581,6 +613,12 @@ describe('getPRChecks', () => { env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) ) + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + expect.objectContaining({ cwd: '/repo-root', wslDistro: 'Ubuntu', host: 'github.com' }) + ) }) it('uses explicit PR repo for rollup and gh pr checks fallback', async () => { @@ -591,14 +629,18 @@ describe('getPRChecks', () => { stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }]) }) - await getPRChecks('/repo-root', 42, undefined, { owner: 'acme', repo: 'widgets' }) + await getPRChecks('/repo-root', 42, undefined, { + owner: 'acme', + repo: 'widgets', + host: 'github.com' + }) expect(getOwnerRepoMock).not.toHaveBeenCalled() expectGraphQLRollupCall() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 9da948f43c4..07a69a6a4ee 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -10,6 +10,7 @@ const { getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, + getEnterpriseGitHubRepoSlugMock, resolvePRRepositoryCandidatesMock, getRemoteUrlForRepoMock, gitExecFileAsyncMock, @@ -24,6 +25,7 @@ const { getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), resolvePRRepositoryCandidatesMock: vi.fn(), getRemoteUrlForRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -76,6 +78,12 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) @@ -87,15 +95,27 @@ vi.mock('./conflict-summary', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: ( + repository?: { host?: string } | null, + executionOptions?: { wslDistro?: string } + ) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && + !executionOptions?.wslDistro })) import { addPRReviewComment, addPRReviewCommentReply, + getPRCheckDetails, + getPRChecks, + getWorkItemByOwnerRepo, getPRComments, mergePR, removePRReviewers, + rerunPRChecks, requestPRReviewers, resolveReviewThread, setPRAutoMerge, @@ -104,6 +124,14 @@ import { updatePRTitle } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('GitHub PR local runtime routing', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -111,6 +139,13 @@ describe('GitHub PR local runtime routing', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: host-less prRepo backfill and origin resolution use getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) resolvePRRepositoryCandidatesMock.mockReset() getRemoteUrlForRepoMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -127,12 +162,28 @@ describe('GitHub PR local runtime routing', () => { it('routes PR details and mutations through the selected WSL distro', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } const prRepo = { owner: 'acme', repo: 'orca' } + rateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: 1_800_000_000 + }) getOwnerRepoMock.mockResolvedValue(prRepo) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { const endpoint = args.find((arg) => arg.startsWith('repos/acme/orca/')) ?? '' const query = args.find((arg) => arg.startsWith('query=')) ?? '' if (args[0] === 'pr' && args[1] === 'view') { + const jsonFields = args[args.indexOf('--json') + 1] + if (jsonFields === 'id,headRefOid,baseRefName') { + return { + stdout: JSON.stringify({ + id: 'PR_local', + headRefOid: 'head-oid', + baseRefName: 'main' + }) + } + } return { stdout: JSON.stringify({ id: 'PR_kwDO123', @@ -184,7 +235,7 @@ describe('GitHub PR local runtime routing', () => { await getPRComments('/repo-root', 7, { prRepo }, null, localGitOptions) await expect( - resolveReviewThread('/repo-root', 'thread-1', true, null, localGitOptions) + resolveReviewThread('/repo-root', 'thread-1', true, null, prRepo, localGitOptions) ).resolves.toBe(true) await expect( addPRReviewCommentReply( @@ -205,6 +256,7 @@ describe('GitHub PR local runtime routing', () => { repoPath: '/repo-root', connectionId: null, localGitOptions, + prRepo, prNumber: 7, body: 'Inline', commitId: 'head-oid', @@ -219,13 +271,13 @@ describe('GitHub PR local runtime routing', () => { updatePRDetails('/repo-root', 7, { body: 'New body' }, null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - updatePRState('/repo-root', 7, { state: 'closed' }, null, localGitOptions) + updatePRState('/repo-root', 7, { state: 'closed' }, null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - requestPRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + requestPRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - removePRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + removePRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', null, prRepo, localGitOptions) @@ -238,5 +290,290 @@ describe('GitHub PR local runtime routing', () => { expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( true ) + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(rateLimitGuardMock).not.toHaveBeenCalled() + expect(noteRateLimitSpendMock).not.toHaveBeenCalled() + }) + + it('never falls through to the default gh host for an unresolved SSH repository', async () => { + const legacyRepo = { owner: 'team', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect(getPRComments('/remote/repo', 7, { prRepo: legacyRepo }, 'ssh-1')).rejects.toThrow( + 'GitHub remote' + ) + await expect( + getPRChecks('/remote/repo', 7, undefined, legacyRepo, undefined, 'ssh-1') + ).rejects.toThrow('GitHub remote') + await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', legacyRepo)).resolves.toMatchObject({ + ok: false + }) + await expect( + setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect(updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', legacyRepo)).resolves.toBe( + false + ) + await expect(requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1')).resolves.toMatchObject({ + ok: false + }) + await expect(removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1')).resolves.toMatchObject({ + ok: false + }) + await expect( + addPRReviewCommentReply( + '/remote/repo', + 7, + 11, + 'Reply', + undefined, + undefined, + undefined, + 'ssh-1', + legacyRepo + ) + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('refuses unresolved local PR mutations instead of using ambient gh defaults', async () => { + const legacyRepo = { owner: 'team', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect( + resolveReviewThread('/repo-root', 'thread-1', true, null, legacyRepo) + ).resolves.toBe(false) + await expect(updatePRTitle('/repo-root', 7, 'New title', null, legacyRepo)).resolves.toBe(false) + await expect( + requestPRReviewers('/repo-root', 7, ['octo'], null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect( + removePRReviewers('/repo-root', 7, ['octo'], null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect(mergePR('/repo-root', 7, 'squash', null, legacyRepo)).resolves.toMatchObject({ + ok: false + }) + await expect( + setPRAutoMerge('/repo-root', 7, true, 'squash', null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('host-qualifies SSH-backed GitHub Enterprise review reads and mutations', async () => { + const enterpriseRepo = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepo) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/team/orca/')) ?? '' + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (args[0] === 'pr' && args[1] === 'checks') { + return { stdout: '[]' } + } + if (args[0] === 'pr' && args[1] === 'view') { + if (args.includes('id,headRefOid,baseRefName')) { + return { + stdout: JSON.stringify({ + id: 'PR_enterprise', + headRefOid: 'head-sha', + baseRefName: 'main' + }) + } + } + return { + stdout: JSON.stringify({ + number: 7, + title: 'Enterprise PR', + state: 'OPEN', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: { login: 'pr-author' }, + headRefName: 'feature', + baseRefName: 'main' + }) + } + } + if (query.includes('reviewThreads')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + if ( + endpoint.endsWith('/issues/7/comments?per_page=100') || + endpoint.endsWith('/pulls/7/reviews?per_page=100') + ) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/commits/head-sha/check-runs?per_page=100')) { + return { + stdout: JSON.stringify({ + check_runs: [ + { + id: 88, + name: 'lint', + status: 'completed', + conclusion: 'failure', + details_url: 'https://github.acme-corp.com/team/orca/actions/runs/77/job/88' + } + ] + }) + } + } + if (endpoint.endsWith('/commits/head-sha/status?per_page=100')) { + return { stdout: JSON.stringify({ statuses: [] }) } + } + if (endpoint.endsWith('/commits/head-sha/check-suites?per_page=100')) { + return { stdout: JSON.stringify({ check_suites: [] }) } + } + if (endpoint.endsWith('/check-runs/88')) { + return { + stdout: JSON.stringify({ + id: 88, + name: 'lint', + status: 'completed', + conclusion: 'failure', + details_url: 'https://github.acme-corp.com/team/orca/actions/runs/77/job/88', + output: { title: 'Lint failed', summary: 'One error' } + }) + } + } + if (endpoint.endsWith('/check-runs/88/annotations?per_page=20')) { + return { stdout: '[]' } + } + if (query) { + return { stdout: JSON.stringify({ data: { repository: {} } }) } + } + return { + stdout: JSON.stringify({ + id: 13, + user: null, + body: 'Enterprise inline comment' + }) + } + }) + + await expect( + getWorkItemByOwnerRepo('/remote/repo', enterpriseRepo, 7, 'pr', 'ssh-1') + ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) + await expect( + getWorkItemByOwnerRepo('/remote/repo', { owner: 'team', repo: 'orca' }, 7, 'pr', 'ssh-1') + ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) + await expect( + getPRComments('/remote/repo', 7, { prRepo: enterpriseRepo }, 'ssh-1') + ).resolves.toEqual([]) + await expect( + getPRChecks('/remote/repo', 7, undefined, enterpriseRepo, undefined, 'ssh-1') + ).resolves.toEqual([]) + await expect( + getPRCheckDetails('/remote/repo', { checkRunId: 88, prRepo: enterpriseRepo }, 'ssh-1') + ).resolves.toMatchObject({ name: 'lint', conclusion: 'failure' }) + await expect( + rerunPRChecks( + '/remote/repo', + 7, + { headSha: 'head-sha', failedOnly: true, prRepo: enterpriseRepo }, + 'ssh-1' + ) + ).resolves.toEqual({ ok: true, count: 1 }) + await expect( + resolveReviewThread('/remote/repo', 'thread-1', true, 'ssh-1', enterpriseRepo) + ).resolves.toBe(true) + await expect( + addPRReviewCommentReply( + '/remote/repo', + 7, + 11, + 'Enterprise reply', + 'thread-1', + 'src/enterprise.ts', + 10, + 'ssh-1', + enterpriseRepo + ) + ).resolves.toMatchObject({ ok: true }) + await expect( + addPRReviewComment({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + prRepo: enterpriseRepo, + prNumber: 7, + body: 'Enterprise inline comment', + commitId: 'head-sha', + path: 'src/enterprise.ts', + line: 10 + }) + ).resolves.toMatchObject({ ok: true }) + await expect( + updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', enterpriseRepo) + ).resolves.toBe(true) + await expect( + updatePRDetails('/remote/repo', 7, { body: 'New body' }, 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect( + updatePRState('/remote/repo', 7, { state: 'closed' }, 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/remote/repo', 7, false, 'squash', 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', enterpriseRepo)).resolves.toEqual({ + ok: true + }) + + const prViewCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args[0] === 'pr' && args[1] === 'view' + ) + // The runner host-qualifies argv at spawn time from options.host, so the + // mocked call sees the unqualified --repo plus the host in exec options. + expect(prViewCall?.[0]).toEqual(expect.arrayContaining(['--repo', 'team/orca'])) + expect(prViewCall?.[1]).toEqual({ host: 'github.acme-corp.com' }) + const prCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr') + expect( + prCalls.every( + ([args]) => args.includes('--repo') && args[args.indexOf('--repo') + 1] === 'team/orca' + ) + ).toBe(true) + const apiCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'api') + expect(apiCalls.length).toBeGreaterThan(0) + expect( + apiCalls.every( + ([args, options]) => + !args.includes('--hostname') && + options.host === 'github.acme-corp.com' && + options.cwd === undefined && + options.wslDistro === undefined + ) + ).toBe(true) }) }) diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts new file mode 100644 index 00000000000..b3f819c2143 --- /dev/null +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' + +const { + execFileAsyncMock, + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolveIssueSourceMock, + gitExecFileAsyncMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuotaMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn((_repo: unknown, bucket: string) => + rateLimitGuardMock(bucket) + ), + noteRepositoryRateLimitSpendMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn(() => true), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + execFileAsync: execFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => ({ + repoPath, + connectionId: connectionId ?? null, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }), + ghRepoExecOptions: (context: { + repoPath: string + connectionId?: string | null + wslDistro?: string + }) => + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn(), + classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + // Mirror production: shared-scope calls delegate to the global guard/spend. + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) + } +}) + +import { + countWorkItems, + listWorkItems, + _resetMergeQueueCacheForTests, + _resetOwnerRepoCache +} from './client' +import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' +import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) +// Why: split from client-work-items.test.ts to keep both suites under the +// max-lines cap; this file owns query/paging request-shaping cases. +describe('listWorkItems query paging', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolveIssueSourceMock.mockReset() + gitExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockClear() + noteRepositoryRateLimitSpendMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReturnValue(true) + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + // Why: preference-aware `listWorkItems` calls `resolveIssueSource`. + // Route through the same `getIssueOwnerRepoMock` so existing tests that + // only set up `getIssueOwnerRepoMock` continue to work. + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueOwnerRepoMock(), + fellBack: false + })) + getOwnerRepoForRemoteMock.mockResolvedValue(null) + _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() + _resetGhCwdRepoNegativeCache() + }) + + it('returns zero for oversized count queries before resolving repo sources', async () => { + const secret = 'main-github-work-items-secret' + const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) + + await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(releaseMock).not.toHaveBeenCalled() + }) + + it('uses an implicit WSL UNC cwd for search quota accounting', async () => { + const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' } + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` + getIssueOwnerRepoMock.mockResolvedValueOnce(repository) + getOwnerRepoMock.mockResolvedValueOnce(repository) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '3' }) + spendsSharedGitHubComQuotaMock.mockReturnValue(false) + + await expect(countWorkItems(repoPath, 'is:issue is:open')).resolves.toBe(3) + + const executionOptions = { cwd: repoPath, host: 'github.com' } + expect(spendsSharedGitHubComQuotaMock).toHaveBeenCalledWith(repository, executionOptions) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + repository, + 'search', + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'search', + 1, + executionOptions + ) + }) + + it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( + expect.arrayContaining(['--review-requested']), + expect.anything() + ) + }) + + it('uses the requested numbered Search API page for issues', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, + '--jq', + '.items' + ], + { cwd: '/repo-root' } + ) + }) + + it('fetches and slices stable PR results for the requested numbered page', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify( + [1, 2, 3, 4].map((number) => ({ + number, + title: `PR ${number}`, + state: 'OPEN', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: `2026-07-0${number}T00:00:00Z`, + author: { login: 'octocat' }, + isDraft: false, + headRefName: `feature/${number}`, + headRefOid: `head-${number}`, + baseRefName: 'main' + })) + ) + }) + + const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), + { cwd: '/repo-root' } + ) + expect(items.map((item) => item.number)).toEqual([4, 3]) + }) + + it('filters pull request rows out of issue Search API results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 2, + title: 'PR-shaped search row', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/2', + labels: [], + updated_at: '2026-07-02T00:00:00Z', + user: { login: 'octocat' }, + pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } + }, + { + number: 1, + title: 'Issue row', + state: 'open', + html_url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updated_at: '2026-07-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(items.map((item) => item.id)).toEqual(['issue:1']) + }) +}) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index fa4f90dcd29..0ac756c9b54 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' const { execFileAsyncMock, @@ -20,7 +21,7 @@ const { getOwnerRepoForRemoteMock: vi.fn(), resolveIssueSourceMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -64,9 +65,46 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + // Mirror production: shared-scope calls delegate to the global guard/spend. + repositoryRateLimitGuard: vi.fn((_repo: unknown, bucket: string) => rateLimitGuardMock(bucket)), + noteRepositoryRateLimitSpend: vi.fn((_repo: unknown, bucket: string, cost?: number) => + noteRateLimitSpendMock(bucket, cost) + ), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) + } +}) + import { countWorkItems, listWorkItems, @@ -76,6 +114,14 @@ import { import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('listWorkItems', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -111,6 +157,21 @@ describe('listWorkItems', () => { _resetGhCwdRepoNegativeCache() }) + it('routes GHES work-item listing through the Enterprise host', async () => { + const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + getIssueOwnerRepoMock.mockResolvedValue(ghes) + getOwnerRepoMock.mockResolvedValue(ghes) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10) + + const calls = ghExecFileAsyncMock.mock.calls + expect(calls.length).toBeGreaterThan(0) + // Why: every list spawn must pin options.host so the runner targets the + // Enterprise server instead of gh's default host. + expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true) + }) + it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) @@ -292,12 +353,9 @@ describe('listWorkItems', () => { null, localGitOptions ) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/repo-root', - 'origin', - null, - localGitOptions - ) + // Why: the suite bridge routes origin through getOwnerRepoMock and non-origin + // remotes through getOwnerRepoForRemoteMock. + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/repo-root', 'upstream', @@ -595,120 +653,6 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - it('returns zero for oversized count queries before resolving repo sources', async () => { - const secret = 'main-github-work-items-secret' - const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) - - await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) - - expect(resolveIssueSourceMock).not.toHaveBeenCalled() - expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(acquireMock).not.toHaveBeenCalled() - expect(releaseMock).not.toHaveBeenCalled() - }) - - it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( - expect.arrayContaining(['--review-requested']), - expect.anything() - ) - }) - - it('uses the requested numbered Search API page for issues', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, - '--jq', - '.items' - ], - { cwd: '/repo-root' } - ) - }) - - it('fetches and slices stable PR results for the requested numbered page', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify( - [1, 2, 3, 4].map((number) => ({ - number, - title: `PR ${number}`, - state: 'OPEN', - url: `https://github.com/acme/widgets/pull/${number}`, - labels: [], - updatedAt: `2026-07-0${number}T00:00:00Z`, - author: { login: 'octocat' }, - isDraft: false, - headRefName: `feature/${number}`, - headRefOid: `head-${number}`, - baseRefName: 'main' - })) - ) - }) - - const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(items.map((item) => item.number)).toEqual([4, 3]) - }) - - it('filters pull request rows out of issue Search API results', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 2, - title: 'PR-shaped search row', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/2', - labels: [], - updated_at: '2026-07-02T00:00:00Z', - user: { login: 'octocat' }, - pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } - }, - { - number: 1, - title: 'Issue row', - state: 'open', - html_url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updated_at: '2026-07-01T00:00:00Z', - user: { login: 'octocat' } - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') - - expect(items.map((item) => item.id)).toEqual(['issue:1']) - }) - it('returns open issues and PRs for the all-open preset query', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 21fc7cc60b8..70c4ea276b4 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' type RateLimitGuardResult = | { blocked: false } @@ -75,6 +76,9 @@ vi.mock('./gh-utils', () => ({ if (lower.includes('rate limit')) { return { type: 'rate_limited', message: stderr } } + if (lower.includes('resource not accessible')) { + return { type: 'permission_denied', message: stderr } + } return { type: 'unknown', message: stderr } }, parseGitHubOwnerRepo: (remoteUrl: string) => { @@ -101,9 +105,66 @@ vi.mock('./local-git-config-signature', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + // Why: the repository-scoped guards share the same bucket-keyed budget as the + // legacy ones, so delegate to the existing mocks to keep per-bucket blocking + // and spend assertions working unchanged. + repositoryRateLimitGuard: (_repository: unknown, bucket: string) => rateLimitGuardMock(bucket), + noteRepositoryRateLimitSpend: (_repository: unknown, bucket: string) => + noteRateLimitSpendMock(bucket), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites inject repo identities through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks so per-test setups + // keep driving resolution without real enterprise probes. + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null + ) => getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId), + getOriginGitHubApiRepository: async ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => { + // Prefer the remote-specific mock (production origin path); fall back for + // suites that only configure getOwnerRepo. + const fromRemote = await getOwnerRepoForRemoteMock( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + const slug = fromRemote ?? (await getOwnerRepoMock(repoPath, connectionId, localGitOptions)) + // Mirror production: dotcom origin slugs come back pinned to github.com. + return slug ? { host: 'github.com', ...slug } : slug + }, + getIssueGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => { + const slug = await getIssueOwnerRepoMock(repoPath, connectionId) + // Mirror production: issue slugs come back host-qualified to github.com. + return slug ? { host: 'github.com', ...slug } : slug + }, + resolveIssueGitHubApiRepositorySource: async ( + repoPath: string, + _preference: unknown, + connectionId?: string | null + ) => { + const slug = await getIssueOwnerRepoMock(repoPath, connectionId) + return { source: slug ? { host: 'github.com', ...slug } : slug, fellBack: false } + } + } +}) + import { checkOrcaStarred, getPRComments, @@ -112,6 +173,7 @@ import { getRepoSlug, getRepoUpstream, getWorkItem, + getWorkItemByOwnerRepo, getPullRequestPushTarget, mergePR, resolveReviewThread, @@ -128,6 +190,14 @@ import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('checkOrcaStarred', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -174,6 +244,11 @@ describe('getPRForBranch', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: resolveGitHubRepoExecution probes origin via getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -3546,17 +3621,27 @@ describe('getPRForBranch', () => { ? { owner: 'fsdwen', repo: 'orca' } : { owner: 'stablyai', repo: 'orca' } ) + // Why: getRepoSlug imports getOriginGitHubApiRepository; the suite bridge + // prefers getOwnerRepoForRemote for origin, so set both seams. + getOwnerRepoMock.mockResolvedValue({ owner: 'fsdwen', repo: 'orca' }) - await expect(getRepoSlug('/repo-root')).resolves.toEqual({ owner: 'fsdwen', repo: 'orca' }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) + await expect(getRepoSlug('/repo-root')).resolves.toEqual({ + owner: 'fsdwen', + repo: 'orca', + host: 'github.com' + }) }) it('resolves a distinct upstream remote as the repo upstream', async () => { // getRepoUpstream probes origin then upstream via getOwnerRepoForRemote (#7331). - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) - .mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'tmchow', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'origin' + ? { owner: 'tmchow', repo: 'orca' } + : { owner: 'stablyai', repo: 'orca' } + ) + // Why: the suite bridge returns getOwnerRepoForRemote fixtures as-is (no host pin). await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' @@ -3566,9 +3651,12 @@ describe('getPRForBranch', () => { }) it('does not treat a same-repository upstream remote as a fork', async () => { - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'StablyAI', repo: 'Orca' }) - .mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'StablyAI', repo: 'Orca' }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'origin' + ? { owner: 'StablyAI', repo: 'Orca' } + : { owner: 'stablyai', repo: 'orca' } + ) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ isFork: false, parent: null }) }) @@ -3576,8 +3664,10 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['repo', 'view', 'StablyAI/Orca', '--json', 'isFork,parent'], - { cwd: '/repo-root', timeout: 10_000 } + // Why: positional slugs are explicit about github.com too, so GH_HOST + // cannot redirect them. + ['repo', 'view', 'github.com/StablyAI/Orca', '--json', 'isFork,parent'], + { cwd: '/repo-root', host: 'github.com', timeout: 10_000 } ) }) @@ -3588,7 +3678,7 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(getOwnerRepoForRemoteMock).toHaveBeenCalledTimes(1) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined, {}) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -3605,7 +3695,75 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', - repo: 'orca' + repo: 'orca', + // Why: fork parents live on the same server as the fork's origin. + host: 'github.com' + }) + }) + + it('routes GHES push-target probes through the Enterprise host', async () => { + const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ghes], + headRepo: ghes + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(ghes) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'feature', + repo: { + full_name: 'team/orca', + name: 'orca', + clone_url: 'https://github.acme-corp.com/team/orca.git', + ssh_url: 'git@github.acme-corp.com:team/orca.git', + owner: { login: 'team' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ + pushTarget: { remoteName: 'origin', branchName: 'feature' } + }) + // Why: the candidate probe must pin options.host so the runner targets the + // Enterprise server instead of gh's default host. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/team/orca/pulls/7'], + expect.objectContaining({ host: 'github.acme-corp.com' }) + ) + }) + + it('does not confuse same-slug PR repositories across GitHub hosts', async () => { + const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + const dotCom = { owner: 'team', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [enterprise, dotCom], + headRepo: dotCom + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(dotCom) + getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:team/orca.git') + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'feature', + repo: { + full_name: 'team/orca', + name: 'orca', + clone_url: 'https://github.acme-corp.com/team/orca.git', + ssh_url: 'git@github.acme-corp.com:team/orca.git', + owner: { login: 'team' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ + pushTarget: { + remoteName: 'pr-team-orca', + branchName: 'feature', + remoteUrl: 'git@github.acme-corp.com:team/orca.git' + } }) }) @@ -3651,6 +3809,45 @@ describe('getPRForBranch', () => { ) }) + it('probes the next PR work-item candidate after a permission denial', async () => { + const upstream = { owner: 'upstream', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) + .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'OPEN', + url: 'https://github.com/fork/orca/pull/42', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: { login: 'octo' }, + isDraft: false + }) + }) + .mockRejectedValueOnce(new Error('metadata unavailable')) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ + number: 42, + title: 'Origin PR', + prRepo: origin + }) + + expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'upstream/orca']) + ) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual(['api', 'repos/upstream/orca/pulls/42']) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + }) + it('normalizes reviewer avatars from REST pull request payloads', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -3687,6 +3884,12 @@ describe('updatePRState', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + // Why: updatePRState resolves origin through getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() @@ -3705,7 +3908,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(acquireMock).toHaveBeenCalledTimes(1) expect(releaseMock).toHaveBeenCalledTimes(1) @@ -3721,7 +3924,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'close', '3977', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3737,7 +3940,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], - {} + { host: 'github.com' } ) }) }) @@ -3749,6 +3952,11 @@ describe('GitHub GraphQL rate-limit guard', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: getPRComments and mutations resolve origin via getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -3815,18 +4023,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) .mockResolvedValueOnce({ stdout: '[]' }) - await getPRComments('/repo-root', 7, { prRepo: { owner: 'stablyai', repo: 'orca' } }, undefined) + await getPRComments( + '/repo-root', + 7, + { prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }, + undefined + ) expect(getOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', '--cache', '60s', 'repos/stablyai/orca/issues/7/comments?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/stablyai/orca/pulls/7/reviews?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3850,10 +4063,18 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValue({ stdout: '', stderr: '' }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: true }) await expect( - updatePRTitle('/repo-root', 7, 'New title', undefined, { owner: 'stablyai', repo: 'orca' }) + updatePRTitle('/repo-root', 7, 'New title', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() @@ -3868,20 +4089,21 @@ describe('GitHub GraphQL rate-limit guard', () => { '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3895,20 +4117,22 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/remote/repo-root', 7, false, 'squash', 'ssh-1', { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], - {} + { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, @@ -3923,14 +4147,16 @@ describe('GitHub GraphQL rate-limit guard', () => { 'expectedHeadOid=head-oid' ]), expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') @@ -3946,7 +4172,8 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) @@ -3973,7 +4200,8 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: false, @@ -3994,21 +4222,23 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect( @@ -4039,7 +4269,11 @@ describe('GitHub GraphQL rate-limit guard', () => { }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: 'This pull request requires review approval before it can be merged.' @@ -4074,7 +4308,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: @@ -4163,6 +4401,82 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(_getMergeQueueCacheSizeForTests()).toBe(256) }) + it('isolates merge metadata for the same slug on different GitHub hosts', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [ + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + ], + headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/7', + statusCheckRollup: [], + updatedAt: '2026-07-16T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock.mockImplementation(async (args, options) => { + if (args.includes('graphql')) { + const enterprise = options?.host === 'github.acme-corp.com' + return { + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + autoMergeAllowed: !enterprise + } + } + }) + } + } + return { stdout: JSON.stringify(prView) } + }) + + const githubDotCom = await getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 7, + 'pr' + ) + const enterprise = await getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, + 7, + 'pr' + ) + + expect(githubDotCom?.autoMergeAllowed).toBe(true) + expect(enterprise?.autoMergeAllowed).toBe(false) + const graphqlCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('graphql')) + expect(graphqlCalls).toHaveLength(2) + expect(graphqlCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) + }) + + it('rejects explicit work-item lookups outside configured repository candidates', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'acme', repo: 'widgets', host: 'github.com' }], + headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + + await expect( + getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'victim', repo: 'secrets', host: 'evil.example.test' }, + 7, + 'pr' + ) + ).resolves.toBeNull() + + expect(acquireMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -4187,7 +4501,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: @@ -4220,7 +4538,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( - mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' }) + mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index d67b3e4405b..6c05d286c2f 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -52,11 +52,6 @@ import { gitExecFileAsync, acquire, release, - getOwnerRepo, - getIssueOwnerRepo, - getOwnerRepoForRemote, - resolvePRRepositoryCandidates, - resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, @@ -85,7 +80,21 @@ import { rememberGhCwdResolutionFailure } from './gh-cwd-repo-negative-cache' import type { GitHubRepoContext } from './github-repository-identity' -import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository' +import { + getGitHubApiRepositoryForRemote, + getIssueGitHubApiRepository, + getOriginGitHubApiRepository, + githubHostExecOptions, + githubRepositorySlugArg, + githubRepositoryWebHost, + resolveGitHubApiRepository, + resolveGitHubApiRepositoryCandidates, + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource, + type GitHubRepoExecOptions, + type GitHubApiRepository +} from './github-api-repository' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -109,12 +118,13 @@ import { import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions' import { getRateLimit, - noteRateLimitSpend, - rateLimitGuard, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' -type GhExecOptions = ReturnType +type GhExecOptions = GitHubRepoExecOptions type HostedReviewLocalGitOptions = ReturnType const ORCA_REPO = 'stablyai/orca' @@ -175,9 +185,15 @@ function pruneRepositoryMergeMetadataCache(now = Date.now()): void { } } -async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { - await getRateLimit() - const guard = rateLimitGuard(bucket) +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()}` @@ -281,7 +297,7 @@ export async function getPullRequestPushTarget( ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { candidates } = await resolvePRRepositoryCandidates( + const { candidates } = await resolveGitHubApiRepositoryCandidates( repoPath, connectionId, localGitOptions @@ -293,15 +309,15 @@ export async function getPullRequestPushTarget( 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 - } + { ...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. @@ -311,10 +327,15 @@ export async function getPullRequestPushTarget( throw error } } - if (!prStdout) { + if (!prStdout || !matchedRepository) { return null } - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) const pr = JSON.parse(prStdout) as { maintainer_can_modify?: boolean head?: { @@ -341,8 +362,8 @@ export async function getPullRequestPushTarget( } if ( origin && - origin.owner.toLowerCase() === owner.toLowerCase() && - origin.repo.toLowerCase() === repo.toLowerCase() + githubRepoIdentityKey(origin) === + githubRepoIdentityKey({ owner, repo, host: matchedRepository.host }) ) { return { pushTarget: { remoteName: 'origin', branchName }, @@ -723,7 +744,15 @@ function mapPullRequestWorkItem( ? String((item.base as { ref?: unknown }).ref ?? '') : String(item.baseRefName ?? ''), ...(headSha ? { headSha } : {}), - ...(baseOwnerRepo ? { prRepo: { owner: baseOwnerRepo.owner, repo: baseOwnerRepo.repo } } : {}), + ...(baseOwnerRepo + ? { + prRepo: { + owner: baseOwnerRepo.owner, + repo: baseOwnerRepo.repo, + ...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {}) + } + } + : {}), ...(additions !== undefined ? { additions } : {}), ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), @@ -787,12 +816,15 @@ async function hydrateWorkItemRepositoryMergeMetadata( async function fetchIssueWorkItem( repoPath: string, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (ownerRepo) { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], @@ -805,6 +837,12 @@ async function fetchIssueWorkItem( 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 @@ -817,7 +855,7 @@ const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' async function fetchPullRequestReviewFields( number: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise> { try { @@ -849,12 +887,15 @@ async function fetchPullRequestReviewFields( async function fetchPullRequestWorkItem( repoPath: string, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (ownerRepo) { try { const { stdout } = await ghExecFileAsync( @@ -902,6 +943,12 @@ async function fetchPullRequestWorkItem( } } + 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 @@ -909,6 +956,40 @@ async function fetchPullRequestWorkItem( return mapPullRequestWorkItem(JSON.parse(stdout) as Record) } +async function fetchPullRequestWorkItemFromCandidates( + repoPath: string, + number: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { candidates } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + if (candidates.length === 0) { + 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 @@ -1033,9 +1114,8 @@ async function resolvePrWorkItemSource( localGitOptions: LocalGitExecOptions = {} ): Promise { const [originCandidate, upstreamCandidate] = await Promise.all([ - // Why: this caller combines both remotes itself, so it needs origin specifically (getOwnerRepo is upstream-first since #7331). - getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), - getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions) ]) const source = preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate @@ -1102,12 +1182,15 @@ async function listRecentWorkItems( // Why: allSettled so a 403 on the issue side doesn't zero the PR half — partial results + banner (parent doc §2). const [issuesSettled, prsSettled] = await Promise.allSettled([ issueOwnerRepo - ? ghExecFileAsync(issueRequest.args, ghOptions) + ? ghExecFileAsync(issueRequest.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), prOwnerRepo - ? ghExecFileAsync(prRequest.args, ghOptions) + ? ghExecFileAsync(prRequest.args, { ...ghOptions, ...githubHostExecOptions(prOwnerRepo) }) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) @@ -1133,7 +1216,10 @@ async function listRecentWorkItems( prs = (JSON.parse(prsSettled.value.stdout) as Record[]) .slice(prRequest.offset, prRequest.offset + limit) .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) + 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. @@ -1213,7 +1299,10 @@ async function listQueriedWorkItems( }) try { const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(request.args, ghOptions) + ? await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) : await ghCwdResolvedExec(repoContext, request.args, ghOptions) const items = (JSON.parse(stdout) as Record[]) .filter((item) => !('pull_request' in item)) @@ -1247,12 +1336,18 @@ async function listQueriedWorkItems( }) try { const { stdout } = prOwnerRepo - ? await ghExecFileAsync(request.args, ghOptions) + ? await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) : await ghCwdResolvedExec(repoContext, request.args, ghOptions) 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) + const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) successfulRequestCount += 1 if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') @@ -1305,7 +1400,7 @@ export async function listWorkItems( } } const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source @@ -1409,7 +1504,10 @@ async function countWorkItemsForQuery( localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } const { stdout } = await ghExecFileAsync( [ 'api', @@ -1422,16 +1520,13 @@ async function countWorkItemsForQuery( ghOptions ) // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. - noteRateLimitSpend('search') + noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions) return Number.parseInt(stdout.trim(), 10) || 0 } function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { - // Why: GitHub owner/repo names are case-insensitive, so differently-cased remotes are the same repo (don't split into two searches). - return ( - left?.owner.toLowerCase() === right?.owner.toLowerCase() && - left?.repo.toLowerCase() === right?.repo.toLowerCase() - ) + // 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 { @@ -1461,7 +1556,7 @@ export async function countWorkItems( return 0 } const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source @@ -1473,10 +1568,16 @@ export async function countWorkItems( 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). - await getRateLimit() - if (rateLimitGuard('search').blocked) { + if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { + await getRateLimit() + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { return 0 } @@ -1548,13 +1649,11 @@ export async function getRepoSlug( repoPath: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} -): Promise<{ owner: string; repo: string } | null> { - // Why: the slug is the checkout's own identity, so it must stay origin-based (getOwnerRepo is upstream-first since #7331). - return getOwnerRepoForRemote( +): Promise { + return getOriginGitHubApiRepository( repoPath, - 'origin', connectionId, - ...hostedReviewLocalGitOptionArgs(options) + getHostedReviewLocalGitOptions(options) ) } @@ -1570,27 +1669,33 @@ export async function getRepoUpstream( ): Promise { const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} - // Why: must be origin — the fast-path compares it against upstream (getOwnerRepo is upstream-first since #7331). - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, ...localGitArgs) + const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + undefined, + connectionId, + localGitOptions + ) if (!origin) { return null } - const upstreamRemote = await getOwnerRepoForRemote( + const upstreamRemote = await getGitHubApiRepositoryForRemote( repoPath, 'upstream', connectionId, - ...localGitArgs + 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', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'], - // Why: cap latency so a stalled gh process can't hold up repo creation. + ['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. { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...ghOptions, timeout: 10_000 } ) @@ -1600,7 +1705,10 @@ export async function getRepoUpstream( } const owner = data.parent?.owner?.login const repo = data.parent?.name - return data.isFork && owner && repo ? { owner, repo } : null + // 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 { @@ -1681,16 +1789,9 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | return { number: Number(urlMatch[1]), url: urlMatch[0] } } -// Why: bare OWNER/REPO resolves against gh's default host, so qualify with host for GHES; the only host signal for cwd-less SSH repos (#8312). -function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string { - return slug.host && slug.host.toLowerCase() !== 'github.com' - ? `${slug.host}/${slug.owner}/${slug.repo}` - : `${slug.owner}/${slug.repo}` -} - async function findOpenPRByHeadBase(args: { repoPath: string - repoArg: string + repo: GitHubApiRepository head: string base: string connectionId?: string | null @@ -1702,7 +1803,7 @@ async function findOpenPRByHeadBase(args: { 'pr', 'list', '--repo', - args.repoArg, + `${args.repo.owner}/${args.repo.repo}`, '--head', args.head, '--base', @@ -1716,7 +1817,8 @@ async function findOpenPRByHeadBase(args: { ], { ...ghRepoExecOptions(context), - ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)) + ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)), + ...githubHostExecOptions(args.repo) } ) const list = JSON.parse(stdout) as { number?: number; url?: string }[] @@ -1775,15 +1877,12 @@ export async function createGitHubPullRequest( } } - // Why: github.com-only slug parsing returns null for GHES; fall back to the enterprise resolver (#8312). - // Why: use origin, not the fork parent — the unqualified head branch was pushed there (getOwnerRepo upstream-first since #7331). - const ownerRepo = - (await getOwnerRepoForRemote( - repoPath, - 'origin', - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - )) ?? (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) + // 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, @@ -1791,8 +1890,8 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } - // Host-qualified for GHES so gh targets the Enterprise server, not github.com. - const repoArg = ghRepoArg(ownerRepo) + // 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 @@ -1844,6 +1943,7 @@ export async function createGitHubPullRequest( const { stdout } = await ghExecFileAsync(createArgs, { ...ghRepoExecOptions(context), ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), + ...githubHostExecOptions(ownerRepo), timeout: 60_000, idempotent: false }) @@ -1854,7 +1954,7 @@ export async function createGitHubPullRequest( const found = head ? await findOpenPRByHeadBase({ repoPath, - repoArg, + repo: ownerRepo, head, base, connectionId, @@ -1878,7 +1978,7 @@ export async function createGitHubPullRequest( ) { const existing = await findOpenPRByHeadBase({ repoPath, - repoArg, + repo: ownerRepo, head, base, connectionId, @@ -1913,16 +2013,15 @@ export async function getWorkItem( if (type === 'issue') { return await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions ) } if (type === 'pr') { - return await fetchPullRequestWorkItem( + return await fetchPullRequestWorkItemFromCandidates( repoPath, - await getOwnerRepo(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1932,7 +2031,7 @@ export async function getWorkItem( try { const issue = await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1947,9 +2046,8 @@ export async function getWorkItem( throw err } } - return await fetchPullRequestWorkItem( + return await fetchPullRequestWorkItemFromCandidates( repoPath, - await getOwnerRepo(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1963,20 +2061,47 @@ export async function getWorkItem( export async function getWorkItemByOwnerRepo( repoPath: string, - ownerRepo: OwnerRepo, + 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, ownerRepo, number, connectionId, localGitOptions) + return await fetchIssueWorkItem( + repoPath, + matchedRepository, + number, + connectionId, + localGitOptions + ) } return await fetchPullRequestWorkItem( repoPath, - ownerRepo, + matchedRepository, number, connectionId, localGitOptions @@ -2142,19 +2267,17 @@ function cacheRepositoryMergeMetadata( } async function detectRepositoryMergeMetadata( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, branchName: string | undefined, ghOptions: GhExecOptions ): Promise { - const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${ - branchName ?? '__repo__' - }` + const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { return cached.value } - const guard = rateLimitGuard('graphql') + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) if (guard.blocked) { return { mergeQueueRequired: null, autoMergeAllowed: null } } @@ -2179,7 +2302,7 @@ async function detectRepositoryMergeMetadata( } }` try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const args = [ 'api', 'graphql', @@ -2193,7 +2316,10 @@ async function detectRepositoryMergeMetadata( if (branchName) { args.push('-f', `branch=${branchName}`) } - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = await ghExecFileAsync(args, { + ...ghOptions, + ...githubHostExecOptions(ownerRepo) + }) const parsed = JSON.parse(stdout) as { data?: { repository?: { @@ -2271,7 +2397,7 @@ async function hydrateBranchLookupWithExactPR( } async function getRestPRForBranch( - prRepo: OwnerRepo, + prRepo: GitHubApiRepository, headOwner: string, branchName: string, ghOptions: ReturnType @@ -2279,7 +2405,7 @@ async function getRestPRForBranch( const head = encodeURIComponent(`${headOwner}:${branchName}`) const { stdout } = await ghExecFileAsync( ['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`], - ghOptions + { ...ghOptions, ...githubHostExecOptions(prRepo) } ) const list = JSON.parse(stdout) as RestPullRequest[] const pr = list[0] @@ -2287,7 +2413,7 @@ async function getRestPRForBranch( } async function getFallbackPRListForBranch( - prRepo: OwnerRepo, + prRepo: GitHubApiRepository, branchName: string, ghOptions: ReturnType ): Promise { @@ -2306,7 +2432,7 @@ async function getFallbackPRListForBranch( '--json', PR_BRANCH_LIST_JSON_FIELDS ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(prRepo) } ) const list = JSON.parse(stdout) as PullRequestLookupData[] return list[0] ?? null @@ -2395,10 +2521,6 @@ function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch return parsed } -function prOwnerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` -} - function shouldRetryTrackedUpstreamBranch( upstreamBranch: TrackedUpstreamBranch, branchName: string, @@ -2411,7 +2533,7 @@ function shouldRetryTrackedUpstreamBranch( if (!headRepo) { return true } - return prOwnerRepoKey(upstreamHeadRepo) !== prOwnerRepoKey(headRepo) + return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo) } async function getTrackedUpstreamBranch( @@ -2695,19 +2817,19 @@ async function lookupPRByBranchName(args: { } async function getRestPRByNumber( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType ): Promise { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } async function getPRByNumber( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType ): Promise { @@ -2722,7 +2844,7 @@ async function getPRByNumber( '--json', PR_LOOKUP_JSON_FIELDS ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) return hydratePullRequestLookupData( ownerRepo, @@ -2832,10 +2954,10 @@ export async function getPRForBranch( return outcome.kind === 'found' ? outcome.pr : null } -// Why: exact-linked fallback returns dataRepo=null; derive the PR's repo from its web URL (host-agnostic for GHE) so the merged-PR membership probe can run. +// 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[1], repo: match[2] } : 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( @@ -2858,11 +2980,16 @@ export async function getPRForBranchOutcome( await acquire() try { - const { candidates, headRepo } = await resolvePRRepositoryCandidates( + const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates( repoPath, connectionId, - ...localGitArgs + 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() } + } let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null let dataHeadRepo: OwnerRepo | null = headRepo @@ -2972,11 +3099,11 @@ export async function getPRForBranchOutcome( ) if (upstreamBranch) { const upstreamHeadRepo = - (await getOwnerRepoForRemote( + (await getGitHubApiRepositoryForRemote( repoPath, upstreamBranch.remoteName, connectionId, - ...localGitArgs + localGitOptions )) ?? headRepo if ( upstreamHeadRepo && @@ -3321,7 +3448,7 @@ function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null { } function mapGraphQLPendingApprovalCheckSuite( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, suite: GraphQLCheckSuite, headSha: string | null | undefined, index: number @@ -3338,7 +3465,7 @@ function mapGraphQLPendingApprovalCheckSuite( } function mapGraphQLPRChecksResponse( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, response: GraphQLPRChecksResponse ): PRCheckDetail[] | null { const pullRequest = response.data?.repository?.pullRequest @@ -3380,7 +3507,7 @@ function mapGraphQLPRChecksResponse( } async function getPRChecksViaRestFallback( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, headSha: string | undefined, ghOptions: GhExecOptions, noCache?: boolean @@ -3389,7 +3516,7 @@ async function getPRChecksViaRestFallback( return null } try { - await assertRateLimitBudget('core') + await assertRateLimitBudget('core', ownerRepo, ghOptions) } catch (err) { console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err) return null @@ -3407,7 +3534,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const checkRunData = JSON.parse(stdout) as { check_runs?: RestCheckRun[] } @@ -3424,7 +3551,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const statusData = JSON.parse(statusResult.stdout) as { statuses?: RestCommitStatus[] } @@ -3446,7 +3573,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const suitesData = JSON.parse(suitesResult.stdout) as { check_suites?: RestCheckSuite[] } @@ -3481,16 +3608,23 @@ export async function getPRChecks( repoPath: string, prNumber: number, headSha?: string, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, options?: { noCache?: boolean }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { void headSha - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + 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') + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) await acquire() try { const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] @@ -3505,7 +3639,7 @@ export async function getPRChecks( } throw err }) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] return data.map((d) => ({ name: d.name, @@ -3522,7 +3656,7 @@ export async function getPRChecks( if (ownerRepo) { let canUseGraphQLRollup = true try { - await assertRateLimitBudget('graphql') + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) } catch (err) { canUseGraphQLRollup = false console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err) @@ -3548,7 +3682,7 @@ export async function getPRChecks( ], ghOptions ) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const checks = mapGraphQLPRChecksResponse( ownerRepo, JSON.parse(stdout) as GraphQLPRChecksResponse @@ -3608,11 +3742,11 @@ function getPendingApprovalCheckSuiteName( } function getPendingApprovalCheckSuiteUrl( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, headSha: string, suiteId: number | null | undefined ): string { - const base = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` + const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` return typeof suiteId === 'number' && Number.isFinite(suiteId) ? `${base}#check-suite-${suiteId}` : base @@ -3695,7 +3829,7 @@ function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): str async function attachFailedJobLogTails( jobs: PRCheckRunDetails['jobs'], - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, ghOptions: GhExecOptions ): Promise { const failedJobs = jobs @@ -3707,7 +3841,8 @@ async function attachFailedJobLogTails( // 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 cacheKey = getCheckJobLogTailCacheKey(job) + const jobCacheKey = getCheckJobLogTailCacheKey(job) + const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null if (!cacheKey) { continue } @@ -3751,13 +3886,17 @@ export async function getPRCheckDetails( workflowRunId?: number checkName?: string url?: string | null - prRepo?: OwnerRepo | null + prRepo?: GitHubApiRepository | null }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + args.prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return null } @@ -3845,12 +3984,20 @@ function parseActionsRunId(url: string | null | undefined): number | undefined { export async function rerunPRChecks( repoPath: string, prNumber: number, - options: { headSha?: string; failedOnly?: boolean } = {}, + options: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubApiRepository | null + } = {}, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + 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' } } @@ -3983,14 +4130,21 @@ query($owner: String!, $repo: String!, $pr: Int!) { export async function getPRComments( repoPath: string, prNumber: number, - options?: { noCache?: boolean; prRepo?: OwnerRepo | null }, + options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = options?.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + 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') + await assertRateLimitBudget('core', ownerRepo, ghOptions) } await acquire() try { @@ -4000,12 +4154,12 @@ export async function getPRComments( 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 = rateLimitGuard('graphql') + const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> if (reviewThreadsGuard.blocked) { reviewThreadsFetch = Promise.resolve(null) } else { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) reviewThreadsFetch = ghExecFileAsync( [ 'api', @@ -4034,7 +4188,7 @@ export async function getPRComments( ghOptions ) ]) - noteRateLimitSpend('core', 2) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) // Parse issue comments (REST) type RESTComment = { @@ -4184,7 +4338,7 @@ export async function getPRComments( ['pr', 'view', String(prNumber), '--json', 'comments'], ghOptions ) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const data = JSON.parse(stdout) as { comments: { author: { login: string } @@ -4216,13 +4370,20 @@ export async function setPRFileViewed(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null pullRequestId: string path: string viewed: boolean }): Promise { - const ghOptions = ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + 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 }) { @@ -4261,12 +4422,21 @@ export async function resolveReviewThread( 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 ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const guard = rateLimitGuard('graphql') + 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})` @@ -4275,7 +4445,7 @@ export async function resolveReviewThread( } await acquire() try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) await ghExecFileAsync( ['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`], ghOptions @@ -4329,11 +4499,15 @@ export async function addPRReviewCommentReply( path?: string, line?: number, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4372,10 +4546,12 @@ export async function addPRReviewComment( localGitOptions?: LocalGitExecOptions } ): Promise { - const ghOptions = ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions ) - const ownerRepo = await getOwnerRepo(args.repoPath, args.connectionId, args.localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4433,11 +4609,18 @@ export async function mergePR( prNumber: number, method: 'merge' | 'squash' | 'rebase' = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + 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 mergeBlocker = await getPRMergeBlocker( @@ -4477,11 +4660,18 @@ export async function setPRAutoMerge( enabled: boolean, method: GitHubPRMergeMethod = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + 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) { @@ -4521,7 +4711,7 @@ type PRAutoMergeIdentity = { async function getPRAutoMergeIdentity( prNumber: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS] @@ -4540,7 +4730,7 @@ async function getPRAutoMergeIdentity( async function runPRAutoMergeCommand( prNumber: number, method: GitHubPRMergeMethod, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`] @@ -4555,7 +4745,7 @@ async function runPRAutoMergeCommand( async function shouldUseMergeQueueAutoMerge( pr: PRAutoMergeIdentity, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { if (!ownerRepo || !pr.baseRefName) { @@ -4568,7 +4758,7 @@ async function shouldUseMergeQueueAutoMerge( async function enablePRAutoMerge( prNumber: number, method: GitHubPRMergeMethod, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) @@ -4612,7 +4802,7 @@ async function enablePRAutoMerge( async function getPRMergeBlocker( repoPath: string, prNumber: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} @@ -4679,11 +4869,15 @@ export async function updatePRState( prNumber: number, updates: GitHubPullRequestStateUpdate, connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4713,14 +4907,22 @@ export async function requestPRReviewers( 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 ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + 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(',')] @@ -4746,14 +4948,22 @@ export async function removePRReviewers( 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 ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + 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(',')] @@ -4782,11 +4992,18 @@ export async function updatePRTitle( prNumber: number, title: string, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--title', title] @@ -4810,11 +5027,15 @@ export async function updatePRDetails( prNumber: number, updates: { title?: string; body?: string }, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index e9a783b75ad..23f3eb8b96e 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -209,6 +209,11 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: getPRForBranch resolves origin through getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -246,7 +251,7 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open // The head-branch lookup still runs (state=all) and sees the closed PR… expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/pulls?head=acme%3Amaster&state=all&per_page=1'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) // …but the default-branch guard discards it: no PR on the trunk (#9171). expect(pr).toBeNull() diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts new file mode 100644 index 00000000000..fdf921a92ad --- /dev/null +++ b/src/main/github/github-api-repository.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitHubEnterpriseRepository from './github-enterprise-repository' +import type * as GhUtils from './gh-utils' + +const { + getEnterpriseGitHubRepoSlugMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + isGitHubHostAuthenticatedMock +} = vi.hoisted(() => ({ + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + isGitHubHostAuthenticatedMock: vi.fn() +})) + +vi.mock('./gh-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getOwnerRepo: getOwnerRepoMock, + // Why: origin resolution uses getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: getOwnerRepoForRemoteMock +})) + +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: isGitHubHostAuthenticatedMock +})) + +import { + _resetOriginGitHubApiRepositoryCache, + getOriginGitHubApiRepository, + githubHostExecOptions, + resolveGitHubApiRepository, + resolveGitHubRepoExecution +} from './github-api-repository' + +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() + getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null) + getOwnerRepoMock.mockReset().mockResolvedValue(null) + getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null) + isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false) +}) + +describe('githubHostExecOptions', () => { + it('pins every known repository host', () => { + expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.com' })).toEqual({ + host: 'github.com' + }) + expect( + githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }) + ).toEqual({ host: 'github.acme-corp.com' }) + }) + + it('does not invent a host when repository identity is unavailable or legacy', () => { + expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets' })).toEqual({}) + expect(githubHostExecOptions(null)).toEqual({}) + }) +}) + +describe('resolveGitHubRepoExecution', () => { + it('combines local repository and GitHub host execution options', async () => { + const ownerRepo = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + isGitHubHostAuthenticatedMock.mockResolvedValue(true) + + await expect( + resolveGitHubRepoExecution('/repo', ownerRepo, null, { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ + ownerRepo, + ghOptions: { + cwd: '/repo', + wslDistro: 'Ubuntu', + host: 'github.acme-corp.com' + } + }) + expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( + 'github.acme-corp.com', + '/repo', + null, + { wslDistro: 'Ubuntu' } + ) + }) + + it('rejects an explicit Enterprise host absent from the local gh auth inventory', async () => { + await expect( + resolveGitHubApiRepository( + '/remote/repo', + { + owner: 'acme', + repo: 'widgets', + host: 'evil.example.test' + }, + 'ssh-1' + ) + ).resolves.toBeNull() + + expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( + 'evil.example.test', + '/remote/repo', + 'ssh-1', + {} + ) + }) + + it.each([ + { owner: 'acme%2Fadmin', repo: 'widgets' }, + { owner: 'acme', repo: '..' } + ])('rejects repository overrides that could alter a gh REST path: %o', async (repository) => { + await expect( + resolveGitHubApiRepository('/repo', { + ...repository, + host: 'github.com' + }) + ).resolves.toBeNull() + + expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('normalizes github.com without spending an auth inventory probe', async () => { + await expect( + resolveGitHubApiRepository('/repo', { + owner: 'acme', + repo: 'widgets', + host: ' GitHub.COM ' + }) + ).resolves.toEqual({ owner: 'acme', repo: 'widgets', host: 'github.com' }) + + expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('backfills the origin host for a host-less caller-specific resolver', async () => { + const ownerRepo = { owner: 'upstream', repo: 'widgets' } + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'widgets' }) + + await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ + ownerRepo: { ...ownerRepo, host: 'github.com' }, + ghOptions: { cwd: '/repo', host: 'github.com' } + }) + }) + + it('backfills an Enterprise origin host for a host-less caller-specific resolver', async () => { + const ownerRepo = { owner: 'upstream', repo: 'widgets' } + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'fork', + repo: 'widgets', + host: 'github.acme-corp.com' + }) + + await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ + ownerRepo: { ...ownerRepo, host: 'github.acme-corp.com' }, + ghOptions: { cwd: '/repo', host: 'github.acme-corp.com' } + }) + }) + + it('rejects a host-less caller-specific resolver for a connection-backed repository', async () => { + await expect( + resolveGitHubRepoExecution( + '/remote/repo', + async () => ({ owner: 'upstream', repo: 'widgets' }), + 'ssh-1' + ) + ).resolves.toEqual({ ownerRepo: null, ghOptions: {} }) + }) + + it('rejects a host-less caller-specific resolver for an unresolved local repository', async () => { + await expect( + resolveGitHubRepoExecution('/repo', async () => ({ owner: 'upstream', repo: 'widgets' })) + ).resolves.toEqual({ ownerRepo: null, ghOptions: { cwd: '/repo' } }) + }) + + it('preserves an authoritative null from a caller-specific resolver', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'origin', repo: 'widgets' }) + + await expect(resolveGitHubRepoExecution('/repo', async () => null)).resolves.toEqual({ + ownerRepo: null, + ghOptions: { cwd: '/repo' } + }) + + expect(getOwnerRepoMock).not.toHaveBeenCalled() + }) +}) + +describe('origin repository cache', () => { + it('does not cache an indeterminate Enterprise auth probe', async () => { + const enterprise = { + owner: 'acme', + repo: 'widgets', + host: 'github.acme-corp.com' + } + getEnterpriseGitHubRepoSlugMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(enterprise) + + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo')).resolves.toEqual(enterprise) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) + }) + + it('still caches a definitive negative Enterprise probe', async () => { + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts new file mode 100644 index 00000000000..7ef11c9f8fb --- /dev/null +++ b/src/main/github/github-api-repository.ts @@ -0,0 +1,360 @@ +import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' +import { + githubRepoIdentityKey, + isDefaultGitHubHost +} from '../../shared/github-repository-identity-key' +import { + getOwnerRepoForRemote, + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions +} from './gh-utils' +import { + getEnterpriseGitHubRepoSlug, + getEnterpriseGitHubRepoSlugForRemote, + isGitHubHostAuthenticated +} from './github-enterprise-repository' + +export type GitHubApiRepository = GitHubOwnerRepo +export type GitHubRepoExecOptions = ReturnType & { host?: string } +export type GitHubRepoExecution = { + ownerRepo: GitHubApiRepository | null + ghOptions: GitHubRepoExecOptions +} + +type GitHubApiRepositoryResolution = + | GitHubApiRepository + | null + | undefined + | (() => Promise) + +// Why: renderer/RPC repository overrides are interpolated into REST paths. +// Reject path syntax before an authenticated gh process can target it. +const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ + +function isValidGitHubApiRepository(repository: GitHubApiRepository): boolean { + return ( + GITHUB_OWNER_SLUG_RE.test(repository.owner) && + GITHUB_REPO_SLUG_RE.test(repository.repo) && + repository.repo !== '.' && + repository.repo !== '..' + ) +} + +// Why: the enterprise branch spawns an uncached `git remote get-url` (an SSH +// round trip on connection-backed repos) — hot paths like per-file contents +// and viewed-state toggles resolve per call, so cache like ownerRepoCache does. +const ORIGIN_REPO_CACHE_TTL_MS = 30_000 +const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512 +const originRepoCache = new Map() +const originRepoInFlight = new Map>() + +function originRepoCacheKey( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}` +} + +/** @internal - exposed for tests only */ +export function _resetOriginGitHubApiRepositoryCache(): void { + originRepoCache.clear() + originRepoInFlight.clear() +} + +/** @internal - exposed for cache-bound tests only */ +export function _getOriginGitHubApiRepositoryCacheSize(): number { + return originRepoCache.size +} + +function pruneOriginRepoCache(now: number): void { + for (const [key, entry] of originRepoCache) { + if (entry.expiresAt <= now) { + originRepoCache.delete(key) + } + } + while (originRepoCache.size > ORIGIN_REPO_CACHE_MAX_ENTRIES) { + const oldestKey = originRepoCache.keys().next().value + if (oldestKey === undefined) { + return + } + originRepoCache.delete(oldestKey) + } +} + +/** + * Host-qualified repository identity for one remote: github.com remotes come + * from the cached slug parser; any other GitHub-shaped host is auth-gated so a + * non-GitHub forge never routes to the GitHub provider. + */ +export async function getGitHubApiRepositoryForRemote( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: generic PR resolution prefers upstream, but this API represents the + // caller-selected remote exactly (#7331). + const ownerRepo = await getOwnerRepoForRemote( + repoPath, + remoteName, + connectionId, + localGitOptions + ) + if (ownerRepo) { + return { ...ownerRepo, host: 'github.com' } + } + const cacheKey = originRepoCacheKey(repoPath, remoteName, connectionId, localGitOptions) + const now = Date.now() + pruneOriginRepoCache(now) + const cached = originRepoCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.value + } + const inFlight = originRepoInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + const probe = (async () => { + const enterpriseOptions = + Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {} + const slug = + remoteName === 'origin' + ? await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions) + : await getEnterpriseGitHubRepoSlugForRemote( + repoPath, + remoteName, + connectionId, + enterpriseOptions + ) + // Why: undefined means the gh auth inventory could not be read. Caching it + // as a negative would turn a transient spawn failure into a 30-second miss. + if (slug !== undefined) { + originRepoCache.set(cacheKey, { + value: slug, + expiresAt: Date.now() + ORIGIN_REPO_CACHE_TTL_MS + }) + pruneOriginRepoCache(Date.now()) + } + return slug ?? null + })() + originRepoInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (originRepoInFlight.get(cacheKey) === probe) { + originRepoInFlight.delete(cacheKey) + } + } +} + +export async function getOriginGitHubApiRepository( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */ +export async function getIssueGitHubApiRepository( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const upstream = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return upstream + } + return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export type GitHubApiRepositoryCandidates = { + candidates: GitHubApiRepository[] + headRepo: GitHubApiRepository | null +} + +/** Hosted mirror of resolvePRRepositoryCandidates: upstream first, then origin. */ +export async function resolveGitHubApiRepositoryCandidates( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const [upstream, origin] = await Promise.all([ + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) + ]) + const seen = new Set() + const candidates: GitHubApiRepository[] = [] + for (const candidate of [upstream, origin]) { + if (!candidate) { + continue + } + const key = githubRepoIdentityKey(candidate) + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + return { candidates, headRepo: origin } +} + +export type ResolvedGitHubApiRepositorySource = { + source: GitHubApiRepository | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +/** Hosted mirror of resolveIssueSource — same preference semantics. */ +export async function resolveIssueGitHubApiRepositorySource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ), + fellBack: false + } + } + return { + source: await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + fellBack: false + } +} + +export async function resolveGitHubApiRepository( + repoPath: string, + repository?: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (repository && !isValidGitHubApiRepository(repository)) { + return null + } + if (repository?.host) { + const host = repository.host.trim().toLowerCase() + if (!host) { + return null + } + if (isDefaultGitHubHost(host)) { + return { ...repository, host } + } + // Why: client-supplied hosts must match gh's local auth inventory before + // they can receive ambient Enterprise credentials from a host-pinned call. + const authenticated = await isGitHubHostAuthenticated( + host, + repoPath, + connectionId, + localGitOptions + ) + return authenticated ? { ...repository, host } : null + } + const originRepository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + localGitOptions + ) + if (!repository) { + return originRepository + } + // Why: older clients only send owner/repo. The origin still supplies the + // execution host for fork-base slugs on the same GitHub Enterprise server. + if (originRepository?.host) { + return { ...repository, host: originRepository.host } + } + // Why: a host-less identity can honor ambient GH_HOST even with a local cwd. + // Only a resolved origin may supply the execution host for legacy clients. + return null +} + +export function isGitHubDotComRepository(repository: GitHubApiRepository): boolean { + return isDefaultGitHubHost(repository.host) +} + +// Why: the gh runner host-qualifies argv from `options.host`, so every known +// host must be carried through. Pinning github.com prevents a process-level +// GH_HOST from silently redirecting an otherwise unambiguous API request. +export function githubHostExecOptions(repository: GitHubApiRepository | null | undefined): { + host?: string +} { + return repository?.host ? { host: repository.host } : {} +} + +export async function resolveGitHubRepoExecution( + repoPath: string, + repository?: GitHubApiRepositoryResolution, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: issue-scoped paths retain their upstream-first resolver while sharing + // the same repo-scoped and host-scoped gh execution option construction. + const requestedRepository = typeof repository === 'function' ? await repository() : repository + // Why: normalize host-less resolver results without replacing an + // authoritative null with the generic origin fallback. + const ownerRepo = + typeof repository === 'function' && !requestedRepository + ? null + : await resolveGitHubApiRepository( + repoPath, + requestedRepository, + connectionId, + localGitOptions + ) + return { + ownerRepo, + ghOptions: { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + } +} + +export function githubRepositoryWebHost(repository: GitHubApiRepository): string { + return repository.host ?? 'github.com' +} + +/** + * Positional `HOST/OWNER/REPO` argv value (e.g. `gh repo view `). + * Positional slugs bypass the runner's `--repo` qualifier, so they must be + * qualified here whenever the host is known. + */ +export function githubRepositorySlugArg(repository: GitHubApiRepository): string { + const slug = `${repository.owner}/${repository.repo}` + // Why: github.com must be explicit too; otherwise process-level GH_HOST can + // redirect positional `gh repo view OWNER/REPO` calls to an Enterprise host. + return repository.host ? `${repository.host}/${slug}` : slug +} diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index 73de02ddae9..0a361ea1323 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -15,7 +15,8 @@ vi.mock('../git/runner', () => ({ import { _resetGitHubHostAuthCache, getEnterpriseGitHubRepoSlug, - isGitHubHostAuthenticated + isGitHubHostAuthenticated, + isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' function mockOriginRemote(url: string): void { @@ -61,11 +62,9 @@ describe('getEnterpriseGitHubRepoSlug', () => { repo: 'orca', host: 'github.acme-corp.com' }) - // The auth probe targets the remote's host, not a hardcoded github.com. - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - { cwd: '/repo' } - ) + // Why: inventory configured gh hosts without targeting the untrusted remote; + // passing it as --hostname could expose an ambient enterprise token. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) }) it('resolves a GHES SCP-style SSH remote', async () => { @@ -79,6 +78,28 @@ describe('getEnterpriseGitHubRepoSlug', () => { }) }) + it('uses the unique ported auth host for a hostname-only SSH remote', async () => { + mockOriginRemote('git@ghe.acme.com:team/orca.git') + mockHostAuthenticated('ghe.acme.com:8443') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com:8443' + }) + }) + + it('uses the auth inventory host when an HTTPS remote includes a non-default port', async () => { + mockOriginRemote('https://ghe.acme.com:8443/team/orca.git') + mockHostAuthenticated('ghe.acme.com') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com' + }) + }) + it('probes gh in the repository WSL runtime, not the host/default distro', async () => { mockOriginRemote('https://github.acme-corp.com/team/orca.git') mockHostAuthenticated() @@ -87,10 +108,10 @@ describe('getEnterpriseGitHubRepoSlug', () => { localGitExecOptions: { wslDistro: 'Ubuntu' } }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - { cwd: '/repo', wslDistro: 'Ubuntu' } - ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) }) it('leaves github.com to getOwnerRepo without probing gh auth', async () => { @@ -120,6 +141,22 @@ describe('getEnterpriseGitHubRepoSlug', () => { await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() }) + + it('preserves an indeterminate auth inventory failure so a later probe can recover', async () => { + mockOriginRemote('git@ghe.acme.com:team/orca.git') + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) + ) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined() + + mockHostAuthenticated('ghe.acme.com:8443') + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com:8443' + }) + }) }) describe('isGitHubHostAuthenticated', () => { @@ -135,10 +172,7 @@ describe('isGitHubHostAuthenticated', () => { await expect( isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') ).resolves.toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - {} - ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) }) it('caches per runtime+host so detection polling does not re-spawn gh', async () => { @@ -149,6 +183,28 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) + it('coalesces concurrent probes for the same runtime and host', async () => { + let finishProbe: (() => void) | undefined + ghExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = () => + resolve({ + stdout: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + }) + }) + ) + + const first = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + const second = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + finishProbe?.() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + }) + it('does not share cache state across WSL distros', async () => { mockHostAuthenticated() @@ -157,6 +213,84 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('derives cache scope from an implicit WSL UNC repository path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + ghExecFileAsyncMock.mockImplementation(async (_args: string[], options: { cwd?: string }) => ({ + stdout: options.cwd?.includes('Ubuntu') + ? 'github.com\n ✓ Logged in to github.com account other (keyring)' + : 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + })) + + try { + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/native/repo')).resolves.toBe( + true + ) + await expect( + isGitHubHostAuthenticated( + 'github.acme-corp.com', + String.raw`\\wsl.localhost\Ubuntu\home\me\repo` + ) + ).resolves.toBe(false) + await expect( + isGitHubHostAuthenticated( + 'github.acme-corp.com', + String.raw`\\wsl.localhost\Debian\home\me\repo` + ) + ).resolves.toBe(true) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('shares the native gh auth probe across SSH connections', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/a', 'ssh-1') + await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/b', 'ssh-2') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not target an unconfigured remote host with ambient credentials', async () => { + mockHostAuthenticated('github.acme-corp.com') + + await expect(isGitHubHostAuthenticated('evil.example.test', '/repo')).resolves.toBe(false) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) + expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') + }) + + it('does not guess between multiple ported auth hosts for one SSH hostname', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: `ghe.acme.com:8443 + ✓ Logged in to ghe.acme.com:8443 account kelora (keyring) +ghe.acme.com:9443 + ✓ Logged in to ghe.acme.com:9443 account other (keyring)`, + stderr: '' + }) + + await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(false) + }) + + it('treats default web ports as the same auth host', async () => { + mockHostAuthenticated('ghe.acme.com:443') + + await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(true) + }) + + it('validates a global project host by inventory without targeting it', async () => { + mockHostAuthenticated('github.acme-corp.com') + + await expect(isGitHubHostAuthenticatedForGlobalCli('evil.example.test')).resolves.toBe(false) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) + expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') + }) + it('treats a listed host as authenticated even when gh exits non-zero', async () => { ghExecFileAsyncMock.mockRejectedValue( Object.assign(new Error('exit 1'), { diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 94cffe725fa..b6b28e62f4e 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -12,6 +12,7 @@ import { parseGitHubRemoteIdentity, type LocalGitExecOptions } from './github-repository-identity' +import { parseWslPath } from '../wsl' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } @@ -20,25 +21,47 @@ export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } // mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a // GHES remote is not left to fall through to Gitea (#8312). const HOST_AUTH_TTL_MS = 60_000 +const HOST_AUTH_CACHE_MAX_ENTRIES = 512 type HostAuthCacheEntry = { - authenticated: boolean + authenticatedHost: string | null expiresAt: number } const hostAuthCache = new Map() +const hostAuthInFlight = new Map>() -// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an -// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the -// runtime that executes gh, not shared under one "local" bucket. Mirrors the -// runtime scoping used by owner/repo resolution. -function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string { - return connectionId ?? `local:${wslDistro ?? 'host'}` +// Why: connection-backed Git operations execute remotely, but gh intentionally +// executes on the native host. Only WSL selects a distinct gh config/runtime. +function runtimeCacheKey(repoPath: string, wslDistro?: string): string { + const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro + return `local:${resolvedDistro?.toLowerCase() ?? 'host'}` } /** @internal - exposed for tests only */ export function _resetGitHubHostAuthCache(): void { hostAuthCache.clear() + hostAuthInFlight.clear() +} + +/** @internal - exposed for cache-bound tests only */ +export function _getGitHubHostAuthCacheSize(): number { + return hostAuthCache.size +} + +function pruneHostAuthCache(now: number): void { + for (const [key, entry] of hostAuthCache) { + if (entry.expiresAt <= now) { + hostAuthCache.delete(key) + } + } + while (hostAuthCache.size > HOST_AUTH_CACHE_MAX_ENTRIES) { + const oldestKey = hostAuthCache.keys().next().value + if (oldestKey === undefined) { + return + } + hostAuthCache.delete(oldestKey) + } } // Only gh's own stdout/stderr — not the Error.message — counts as an @@ -52,14 +75,117 @@ function ghCommandOutput(error: unknown): string { .join('\n') } +type NormalizedGitHubHost = { + hostname: string + port: string | null + authority: string +} + +function normalizeGitHubHost(host: string): NormalizedGitHubHost | null { + const match = host + .trim() + .toLowerCase() + .match(/^([a-z0-9][a-z0-9.-]*)(?::(\d+))?$/i) + if (!match) { + return null + } + const hostname = match[1] + const rawPort = match[2] ?? null + // Why: default web ports do not distinguish a gh auth host from the same + // hostname without an explicit port. + const port = rawPort === '80' || rawPort === '443' ? null : rawPort + return { hostname, port, authority: port ? `${hostname}:${port}` : hostname } +} + +function authenticatedHostFromInventory(host: string, output: string): string | null { + const requested = normalizeGitHubHost(host) + if (!requested) { + return null + } + const inventory = Array.from( + new Map( + parseAuthStatus(output) + .map((account) => normalizeGitHubHost(account.host)) + .filter((candidate): candidate is NormalizedGitHubHost => candidate !== null) + .map((candidate) => [candidate.authority, candidate]) + ).values() + ) + const exact = inventory.find((candidate) => candidate.authority === requested.authority) + if (exact) { + return exact.authority + } + const compatible = inventory.filter( + (candidate) => + candidate.hostname === requested.hostname && (!requested.port || candidate.port === null) + ) + // Why: an SSH remote has no API port. Only a unique auth-inventory host can + // safely supply it; multiple ported endpoints on one hostname are ambiguous. + return compatible.length === 1 ? compatible[0].authority : null +} + +async function resolveAuthenticatedGitHubHost( + host: string, + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase() + const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}` + const now = Date.now() + pruneHostAuthCache(now) + const cached = hostAuthCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.authenticatedHost + } + const inFlight = hostAuthInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + // Why: provider detection and review loading can probe the same runtime at + // once; coalesce them so one host never spawns duplicate auth subprocesses. + const probe = (async () => { + const execOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + } + let authenticatedHost: string | null + try { + const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'], execOptions) + authenticatedHost = authenticatedHostFromInventory(host, `${stdout}\n${stderr}`) + } catch (error) { + const output = ghCommandOutput(error) + if (!output) { + // Indeterminate (gh missing / spawn failure) — do not cache so a later + // probe (gh installed, tunnel ready, token added) can recover. + return undefined + } + // gh exits non-zero when a host has a token problem but still prints the + // per-host status; trust only hosts that are actually listed. + authenticatedHost = authenticatedHostFromInventory(host, output) + } + hostAuthCache.set(cacheKey, { + authenticatedHost, + expiresAt: Date.now() + HOST_AUTH_TTL_MS + }) + pruneHostAuthCache(Date.now()) + return authenticatedHost + })() + hostAuthInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (hostAuthInFlight.get(cacheKey) === probe) { + hostAuthInFlight.delete(cacheKey) + } + } +} + /** * Whether `gh` is authenticated to `host` from the repository's own runtime. * - * The probe runs `gh auth status --hostname ` with the repo's execution - * options (cwd / WSL distro, or SSH-local like the create path), so a GHES login - * stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred - * from it — is honored instead of the host/default-distro gh. Cached briefly per - * runtime+host so provider-detection polling does not re-spawn gh each time. + * The probe inventories gh's configured hosts and matches `host` locally. It + * deliberately does not pass an untrusted remote host to gh because ambient + * enterprise tokens could otherwise be sent to that host during validation. + * Cached briefly per runtime+host so provider-detection polling stays cheap. */ export async function isGitHubHostAuthenticated( host: string, @@ -67,52 +193,35 @@ export async function isGitHubHostAuthenticated( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const normalizedHost = host.toLowerCase() - const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` - const now = Date.now() - const cached = hostAuthCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return cached.authenticated - } - const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - let authenticated: boolean - try { - await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions) - authenticated = true - } catch (error) { - const output = ghCommandOutput(error) - if (!output) { - // Indeterminate (gh missing / spawn failure) — do not cache so a later - // probe (gh installed, tunnel ready, token added) can recover. - return false - } - // gh exits non-zero when a host has a token problem but still prints the - // per-host status; treat the host as GitHub only when it is actually listed. - authenticated = parseAuthStatus(output).some( - (account) => account.host.toLowerCase() === normalizedHost - ) - } - hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS }) - return authenticated + return Boolean( + await resolveAuthenticatedGitHubHost(host, repoPath, connectionId, localGitOptions) + ) +} + +/** Safely validate a project-selected host without giving the untrusted host + * to gh. Global project calls have no repository cwd, so they use native gh. */ +export function isGitHubHostAuthenticatedForGlobalCli(host: string): Promise { + return isGitHubHostAuthenticated(host, '', 'project-host-validation') } /** - * Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom - * host the user is gh-authenticated to. Returns null for github.com (already - * handled by {@link getOwnerRepo}) and for hosts gh is not logged in to + * Resolve owner/repo for a GitHub Enterprise Server remote — a custom host the + * user is gh-authenticated to. Returns null for github.com (already handled by + * {@link getOwnerRepo}) and for hosts gh is not logged in to * (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider * without a GitHub provider stealing another forge's remote. */ -export async function getEnterpriseGitHubRepoSlug( +export async function getEnterpriseGitHubRepoSlugForRemote( repoPath: string, + remoteName: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} -): Promise { +): Promise { const localGitOptions = getHostedReviewLocalGitOptions(options) const context = githubRepoContext(repoPath, connectionId, localGitOptions) let remoteUrl: string | null try { - remoteUrl = await getRemoteUrlForRepo(context, 'origin') + remoteUrl = await getRemoteUrlForRepo(context, remoteName) } catch { return null } @@ -120,11 +229,24 @@ export async function getEnterpriseGitHubRepoSlug( if (!identity || identity.host === 'github.com') { return null } - const authenticated = await isGitHubHostAuthenticated( + const authenticatedHost = await resolveAuthenticatedGitHubHost( identity.host, repoPath, connectionId, localGitOptions ) - return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null + if (authenticatedHost === undefined) { + return undefined + } + return authenticatedHost + ? { owner: identity.owner, repo: identity.repo, host: authenticatedHost } + : null +} + +export async function getEnterpriseGitHubRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getEnterpriseGitHubRepoSlugForRemote(repoPath, 'origin', connectionId, options) } diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts new file mode 100644 index 00000000000..c0b8b599520 --- /dev/null +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing' + +describe('parseGitHubRemoteIdentity', () => { + it('parses a plain github.com https remote', () => { + expect(parseGitHubRemoteIdentity('https://github.com/team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('parses an SCP-style github.com remote', () => { + expect(parseGitHubRemoteIdentity('git@github.com:team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('preserves a custom port on a GHES https remote', () => { + // The port IS the Enterprise web/API endpoint, so gh must target + // ghe.acme.com:8443, not the portless hostname. + expect(parseGitHubRemoteIdentity('https://ghe.acme.com:8443/team/orca.git')).toEqual({ + host: 'ghe.acme.com:8443', + owner: 'team', + repo: 'orca' + }) + }) + + it('preserves a custom port on a GHES http remote', () => { + expect(parseGitHubRemoteIdentity('http://ghe.acme.com:8080/team/orca.git')).toEqual({ + host: 'ghe.acme.com:8080', + owner: 'team', + repo: 'orca' + }) + }) + + it('drops the default https port so github.com stays bare', () => { + // WHATWG URL omits default ports, so :443 never leaks into the host. + expect(parseGitHubRemoteIdentity('https://github.com:443/team/orca.git')?.host).toBe( + 'github.com' + ) + }) + + it('drops the default https port on a GHES host', () => { + expect(parseGitHubRemoteIdentity('https://ghe.acme.com:443/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('normalizes ssh.github.com:443 (SSH-over-HTTPS) to github.com without a port', () => { + // :443 here is the ssh transport port, not an endpoint — it must not survive. + expect(parseGitHubRemoteIdentity('ssh://git@ssh.github.com:443/team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('drops a custom ssh transport port on a GHES ssh remote', () => { + // ssh://…:2222 is a transport port; gh routes by hostname, so keep only the host. + expect(parseGitHubRemoteIdentity('ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('drops the transport port for git+ssh and git remotes', () => { + expect(parseGitHubRemoteIdentity('git+ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + expect(parseGitHubRemoteIdentity('git://ghe.acme.com:9418/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('lowercases the host while keeping the port', () => { + expect(parseGitHubRemoteIdentity('https://GHE.Acme.COM:8443/team/orca.git')?.host).toBe( + 'ghe.acme.com:8443' + ) + }) + + it('returns null for an unparseable remote', () => { + expect(parseGitHubRemoteIdentity('not-a-remote')).toBeNull() + }) +}) + +describe('parseGitHubOwnerRepo', () => { + it('returns owner/repo for github.com', () => { + expect(parseGitHubOwnerRepo('https://github.com/team/orca.git')).toEqual({ + owner: 'team', + repo: 'orca' + }) + }) + + it('returns null for a custom-port GHES remote (not github.com)', () => { + // GHES is handled by the enterprise resolver, not the github.com fast path, + // and the port must not make a github.com remote look like GHES either. + expect(parseGitHubOwnerRepo('https://ghe.acme.com:8443/team/orca.git')).toBeNull() + }) + + it('still recognizes github.com even with an explicit default port', () => { + expect(parseGitHubOwnerRepo('https://github.com:443/team/orca.git')).toEqual({ + owner: 'team', + repo: 'orca' + }) + }) +}) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index e7bd21242f1..6f13e62014d 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -4,10 +4,17 @@ export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } function normalizeGitHubRemoteHost(host: string): string { const normalizedHost = host.toLowerCase() - // Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos. + // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost } +// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are +// transport-only and must not leak into gh's host identity. +function hostFromRemoteUrl(url: URL): string { + const protocol = url.protocol.toLowerCase() + return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname +} + function parseGitHubRemotePath(path: string): Pick | null { const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') if (parts.length !== 2) { @@ -34,7 +41,7 @@ export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdenti return null } const path = parseGitHubRemotePath(url.pathname) - return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null + return path ? { host: normalizeGitHubRemoteHost(hostFromRemoteUrl(url)), ...path } : null } catch { return null } diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 62750e02458..7613baaf8fd 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -128,7 +128,8 @@ describe('issue #7331: fork PR owner/repo resolution', () => { // fast-path would be skipped. const upstream = await getRepoUpstream(FORK_PATH) - expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) + // Why: origin resolution pins github.com so host-scoped execution is explicit. + expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 28b7b0676c7..1996d7b2b54 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -8,6 +8,7 @@ import { type GitHubRemoteIdentity } from './github-remote-identity-parsing' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export type OwnerRepo = GitHubOwnerRepo @@ -234,7 +235,7 @@ export type PRRepositoryCandidates = { } function ownerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` + return githubRepoIdentityKey(ownerRepo) } export async function resolvePRRepositoryCandidates( diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 0f65fc1572c..88d61c1bcc9 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' import type * as GhUtils from './gh-utils' const { @@ -27,6 +29,45 @@ vi.mock('./gh-utils', async () => { } }) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + const withDotComHost = (repo: T) => + repo ? { host: 'github.com' as const, ...repo } : repo + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks and pin github.com so + // host-less fixtures still pass resolveGitHubApiRepository's host gate. + getIssueGitHubApiRepository: async ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => withDotComHost(await getIssueOwnerRepoMock(repoPath, connectionId, localGitOptions)), + resolveIssueGitHubApiRepositorySource: async ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => { + const result = await resolveIssueSourceMock( + repoPath, + preference, + connectionId, + localGitOptions + ) + return { + ...result, + source: withDotComHost(result?.source) + } + } + } +}) + import { addIssueComment, createIssue, @@ -37,6 +78,14 @@ import { updateIssue } from './issues' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('issue source operations', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -69,7 +118,7 @@ describe('issue source operations', () => { await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -138,6 +187,38 @@ describe('issue source operations', () => { ) }) + it('routes PR conversation comments to the supplied Enterprise host', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 9, + user: { login: 'octo', avatar_url: '', type: 'User' }, + body: 'Enterprise comment', + created_at: '2026-07-16T00:00:00.000Z', + html_url: 'https://github.acme-corp.com/team/orca/pull/7#issuecomment-9' + }) + }) + + await expect( + addIssueComment('/remote/repo', 7, 'Enterprise comment', 'ssh-1', { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + ).resolves.toMatchObject({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'repos/team/orca/issues/7/comments', + '--raw-field', + 'body=Enterprise comment' + ], + expect.objectContaining({ host: 'github.acme-corp.com' }) + ) + }) + it('lists issues from the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) @@ -151,7 +232,7 @@ describe('issue source operations', () => { '120s', 'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -195,7 +276,7 @@ describe('issue source operations', () => { '--raw-field', 'body=Body' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -235,7 +316,7 @@ describe('issue source operations', () => { '--raw-field', 'assignees[]=octo' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -274,17 +355,17 @@ describe('issue source operations', () => { 'labels[]=bug', 'assignees[]=octo' ]), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -405,7 +486,7 @@ describe('issue source operations', () => { }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/924', '--raw-field', 'body=Updated body'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -430,17 +511,17 @@ describe('issue source operations', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -453,7 +534,7 @@ describe('issue source operations', () => { }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index b0f3fa6cb89..45ed0b1a99e 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -15,8 +15,13 @@ import type { } from '../../shared/types' import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' +import { + getIssueGitHubApiRepository, + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource +} from './github-api-repository' // prettier-ignore -import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils' +import { ghExecFileAsync, acquire, release, classifyGhError, classifyListIssuesError, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an @@ -54,9 +59,17 @@ export async function getIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + connectionId, + localGitOptions + ) + // Why: a connection-backed request has no local cwd, so the non-GitHub + // fallback below would let gh target its default repository. Refuse instead. + if (connectionId && !ownerRepo) { + return null + } await acquire() try { if (ownerRepo) { @@ -104,14 +117,31 @@ export async function listIssues( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) + // Why: a connection-backed request has no local cwd, so the non-GitHub + // fallback below would let gh list its default repository. Refuse instead. + if (connectionId && !ownerRepo) { + return { + items: [], + error: { + type: 'not_found', + message: 'Could not resolve GitHub owner/repo for this repository' + } + } + } await acquire() try { if (ownerRepo) { @@ -173,11 +203,17 @@ export async function createIssue( if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) @@ -286,9 +322,12 @@ export async function updateIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -409,10 +448,13 @@ export async function addIssueComment( ownerRepoOverride?: OwnerRepo | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = - ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + ownerRepoOverride ?? + (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)), + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -463,11 +505,17 @@ export async function listLabels( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) @@ -503,11 +551,17 @@ export async function listAssignableUsers( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index 0bb390f6495..7350416ca6f 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -1,5 +1,7 @@ import { ghExecFileAsync } from './gh-utils' -import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' +import { githubHostExecOptions } from './github-api-repository' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' import type { OwnerRepo } from './github-repository-identity' type GhExecOptions = Parameters[1] @@ -59,7 +61,8 @@ export async function isCommitPartOfMergedPR(args: { } const owner = args.ownerRepo.owner const repo = args.ownerRepo.repo - const cacheKey = `${owner.toLowerCase()}/${repo.toLowerCase()}#${args.prNumber}@${oid}` + const cacheKey = `${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}` + const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) } const now = Date.now() pruneMergedPRCommitMembershipCache(now) const cached = membershipCache.get(cacheKey) @@ -68,7 +71,7 @@ export async function isCommitPartOfMergedPR(args: { } // Why blocked stays unknown: hiding a transient branch match is safe, but // callers must not clear a durable linked PR when the probe never ran. - if (rateLimitGuard('core').blocked) { + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { return 'unknown' } try { @@ -79,20 +82,20 @@ export async function isCommitPartOfMergedPR(args: { // found (contained) or a short page proves absence (not-contained); only the // pathological all-full case up to the cap stays 'unknown'. for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) { - if (page > 1 && rateLimitGuard('core').blocked) { + if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { membershipCache.set(cacheKey, { value: 'unknown', expiresAt: now + MEMBERSHIP_ERROR_TTL_MS }) return 'unknown' } - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}` ], - args.ghOptions + ghOptions ) const parsed = JSON.parse(stdout) as unknown // Why: a non-array success payload is a shape mismatch, not an empty page; diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 5ee1a882a62..7298c36af6d 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -2,21 +2,28 @@ request timestamps, and follow-up scheduling against shared module state. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' const { sendMock, sendToTrustedUIRendererMock, getAllWebContentsMock, getPRForBranchOutcomeMock, + getOriginGitHubApiRepositoryMock, getRateLimitMock, - rateLimitGuardMock + noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuardMock, + spendsSharedGitHubComQuotaMock } = vi.hoisted(() => ({ sendMock: vi.fn(), sendToTrustedUIRendererMock: vi.fn(), getAllWebContentsMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), + getOriginGitHubApiRepositoryMock: vi.fn(), getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn() + noteRepositoryRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn() })) vi.mock('electron', () => ({ @@ -29,10 +36,15 @@ vi.mock('./client', () => ({ getPRForBranchOutcome: getPRForBranchOutcomeMock })) +vi.mock('./github-api-repository', () => ({ + getOriginGitHubApiRepository: getOriginGitHubApiRepositoryMock +})) + vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, - noteRateLimitSpend: vi.fn(), - rateLimitGuard: rateLimitGuardMock + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) vi.mock('../ipc/ui', () => ({ @@ -94,9 +106,23 @@ describe('pr-refresh-coordinator', () => { }) getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() + getOriginGitHubApiRepositoryMock.mockReset() + getOriginGitHubApiRepositoryMock.mockResolvedValue({ + owner: 'acme', + repo: 'widgets', + host: 'github.com' + }) getRateLimitMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockImplementation( + (repository: { host?: string } | null, options?: { cwd?: string; wslDistro?: string }) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && + !options?.wslDistro && + !(options?.cwd && isWslUncPath(options.cwd)) + ) getAllWebContentsMock.mockReturnValue([ { id: 1, @@ -569,6 +595,75 @@ describe('pr-refresh-coordinator', () => { expect(pausedEvents).toHaveLength(0) }) + it.each([ + { + scope: 'GitHub Enterprise', + repository: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, + repoPath: '/repo', + localGitOptions: undefined + }, + { + scope: 'WSL', + repository: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + repoPath: '/repo', + localGitOptions: { wslDistro: 'Ubuntu' } + }, + { + scope: 'implicit WSL UNC', + repository: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + repoPath: String.raw`\\wsl.localhost\Ubuntu\home\me\widgets`, + localGitOptions: undefined + } + ])('bypasses the shared budget for $scope background refreshes', async (testCase) => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + getOriginGitHubApiRepositoryMock.mockResolvedValue(testCase.repository) + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'no-pr', + fetchedAt: Date.now() + }) + const executionOptions = { + cwd: testCase.repoPath, + ...testCase.localGitOptions + } + + enqueuePRRefresh( + makeCandidate({ + repoPath: testCase.repoPath, + localGitOptions: testCase.localGitOptions + }), + 'active', + 80, + 1 + ) + + await vi.advanceTimersByTimeAsync(0) + + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + testCase.repository, + 'core', + executionOptions + ) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + testCase.repository, + 'graphql', + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + testCase.repository, + 'core', + 1, + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + testCase.repository, + 'graphql', + 1, + executionOptions + ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + it('does not consume active burst slots for rate-limit pauses', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') // Why: keyed on drained items (one getRateLimit call each) rather than @@ -579,7 +674,7 @@ describe('pr-refresh-coordinator', () => { drainedItems += 1 return { ok: true } }) - rateLimitGuardMock.mockImplementation(() => + repositoryRateLimitGuardMock.mockImplementation(() => drainedItems <= 3 ? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 } : { blocked: false } diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 592bea4870b..70b13ec2e69 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -9,7 +9,14 @@ import type { PRRefreshOutcome } from '../../shared/types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { + getRateLimit, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota +} from './rate-limit' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { sendToTrustedUIRenderer } from '../ipc/ui' @@ -728,11 +735,26 @@ async function drainQueue(): Promise { ) if (isBackground(next.reason)) { - // Why: the probe only warms rateLimitGuard's cache, so it must fail open — GHES with rate limiting disabled 404s every probe (#7553). - await getRateLimit() + const executionOptions = ghRepoExecOptions( + githubRepoContext( + next.candidate.repoPath, + next.candidate.connectionId, + next.candidate.localGitOptions + ) + ) + const repository = await getOriginGitHubApiRepository( + next.candidate.repoPath, + next.candidate.connectionId, + executionOptions + ) + // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + // Why: the probe only warms the cache, so failures must fail open (#7553). + await getRateLimit() + } const buckets = backgroundRefreshBuckets() const blockedGuard = buckets - .map((bucket) => rateLimitGuard(bucket)) + .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) .find((guard) => guard.blocked) if (blockedGuard?.blocked) { const retryAt = blockedGuard.resetAt * 1000 @@ -755,7 +777,7 @@ async function drainQueue(): Promise { noteActiveStart(next) } for (const bucket of buckets) { - noteRateLimitSpend(bucket) + noteRepositoryRateLimitSpend(repository, bucket, 1, executionOptions) } } @@ -922,8 +944,16 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise } // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. + const manualExecutionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const manualRepository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + manualExecutionOptions + ) const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => rateLimitGuard(bucket)) + .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) .find((guard) => guard.blocked) const secondaryGateUntil = manualRetryGates.get(key) const gateUntil = Math.max( diff --git a/src/main/github/project-view-host-auth.test.ts b/src/main/github/project-view-host-auth.test.ts new file mode 100644 index 00000000000..f243c06ed45 --- /dev/null +++ b/src/main/github/project-view-host-auth.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + acquireMock, + releaseMock, + ghExecFileAsyncMock, + hostAuthenticatedMock, + noteRepositoryRateLimitSpendMock +} = vi.hoisted(() => ({ + acquireMock: vi.fn(), + releaseMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + hostAuthenticatedMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('../git/runner', () => ({ + extractExecError: vi.fn(() => ({ stdout: '', stderr: '' })), + ghExecFileAsync: ghExecFileAsyncMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + isGitHubHostAuthenticatedForGlobalCli: hostAuthenticatedMock +})) + +import { runGraphql, runRest } from './project-view/internals' +import { _resetProjectViewCachesForTests, resolveProjectRef } from './project-view' + +describe('project view host authentication boundary', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + releaseMock.mockReset() + ghExecFileAsyncMock.mockReset() + hostAuthenticatedMock.mockReset() + noteRepositoryRateLimitSpendMock.mockReset() + _resetProjectViewCachesForTests() + }) + + it('does not send GraphQL or REST requests to an unconfigured host', async () => { + hostAuthenticatedMock.mockResolvedValue(false) + + const [graphql, rest] = await Promise.all([ + runGraphql('query { viewer { login } }', {}, { host: 'evil.example.test' }), + runRest(['user'], undefined, 'core', { host: 'evil.example.test' }) + ]) + + expect(graphql).toMatchObject({ ok: false, error: { type: 'auth_required' } }) + expect(rest).toMatchObject({ ok: false, error: { type: 'auth_required' } }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + }) + + it('routes a configured Enterprise request to its selected host', async () => { + hostAuthenticatedMock.mockResolvedValue(true) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"data":{"viewer":{"login":"me"}}}', + stderr: '' + }) + + await expect( + runGraphql('query { viewer { login } }', {}, { host: 'github.corp.example' }) + ).resolves.toMatchObject({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(expect.any(Array), { + encoding: 'utf-8', + host: 'github.corp.example' + }) + }) + + it('keeps github.com fast and never probes it as Enterprise', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"data":{"viewer":{"login":"me"}}}', + stderr: '' + }) + + await expect( + runGraphql('query { viewer { login } }', {}, { host: 'github.com' }) + ).resolves.toMatchObject({ ok: true }) + + expect(hostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('uses a pasted github.com URL instead of the ambient Enterprise host', async () => { + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + return query.includes('projectV2') + ? { + stdout: JSON.stringify({ + data: { organization: { projectV2: { id: 'PVT_7', title: 'Roadmap' } } } + }), + stderr: '' + } + : { + stdout: JSON.stringify({ data: { organization: { login: 'acme' } } }), + stderr: '' + } + }) + + await expect( + resolveProjectRef({ + input: 'https://github.com/orgs/acme/projects/7', + host: 'github.corp.example' + }) + ).resolves.toMatchObject({ ok: true, host: 'github.com' }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.every(([, options]) => options.host === 'github.com') + ).toBe(true) + expect(hostAuthenticatedMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 00a8844bb34..1339c5c0fdc 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -68,6 +68,19 @@ describe('classifyProjectError', () => { it('classifies auth-required when gh is not signed in', () => { expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') }) + + it('pins Enterprise auth and scope remediation to the selected host', () => { + expect( + classifyProjectError('gh auth login required', '', 'github.acme.test').message + ).toContain('gh auth login --hostname github.acme.test') + expect( + classifyProjectError( + 'your token has not been granted the required scopes', + '', + 'github.acme.test' + ).message + ).toContain('gh auth refresh --hostname github.acme.test') + }) }) describe('isValidOwnerSlug', () => { @@ -136,6 +149,7 @@ describe('parseProjectPaste', () => { kind: 'org', owner: 'acme', number: 42, + host: 'github.com', viewNumber: 3 }) }) @@ -144,7 +158,8 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ kind: 'user', owner: 'octocat', - number: 1 + number: 1, + host: 'github.com' }) }) @@ -152,6 +167,43 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull() }) + it('accepts enterprise-host URLs only when that host is provided (GHES)', () => { + const url = 'https://github.corp.example/orgs/acme/projects/7/views/2' + expect(parseProjectPaste(url, 'github.corp.example')).toEqual({ + kind: 'org', + owner: 'acme', + number: 7, + host: 'github.corp.example', + viewNumber: 2 + }) + expect(parseProjectPaste(url)).toBeNull() + // github.com URLs still parse when a GHES host is supplied. + expect( + parseProjectPaste('https://github.com/orgs/acme/projects/7', 'github.corp.example') + ).toEqual({ kind: 'org', owner: 'acme', number: 7, host: 'github.com' }) + }) + + it('preserves a GHES custom port while parsing project URLs', () => { + expect( + parseProjectPaste( + 'https://github.corp.example:8443/orgs/acme/projects/7', + 'github.corp.example:8443' + ) + ).toEqual({ + kind: 'org', + owner: 'acme', + number: 7, + host: 'github.corp.example:8443' + }) + }) + + it('rejects credentials and paths that only begin like a Project URL', () => { + expect(parseProjectPaste('https://user:token@github.com/orgs/acme/projects/1')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1evil')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/views/2evil')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/files')).toBeNull() + }) + it('returns null for empty input', () => { expect(parseProjectPaste('')).toBeNull() expect(parseProjectPaste(' ')).toBeNull() @@ -208,6 +260,13 @@ describe('project view owner caches', () => { expect(_getProjectViewOwnerTypeForTests('owner-1')).toBe('user') }) + it('shares owner type probes between implicit and explicit github.com hosts', () => { + _rememberProjectViewOwnerTypeForTests('acme', 'organization') + + expect(_getProjectViewOwnerTypeForTests('acme', 'github.com')).toBe('organization') + expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe(1) + }) + it('LRU-evicts old parent-field retry and warning probes', () => { for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { const scopeKey = `owner-${i}\u0000organization` diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 8e2be18044e..2e53eff357c 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -4,19 +4,23 @@ import { release, extractExecError, ghExecFileAsync, - rateLimitGuard, - noteRateLimitSpend, - classifyProjectError, - driftError, - errorsIndicateParentField, - rateLimitedError, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, runGraphql, isValidOwnerSlug, assertSlug, assertPositiveInt, - type GhGraphqlErrorShape, + projectHostAuthenticationError, + projectGhExecOptions, type GraphqlVars } from './project-view/internals' +import { + classifyProjectError, + driftError, + errorsIndicateParentField, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-view/project-error-classification' import type { GetProjectViewTableArgs, GetProjectViewTableResult, @@ -36,6 +40,7 @@ import type { GitHubProjectViewError, GitHubProjectViewLayout, GitHubProjectViewSummary, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListProjectViewsArgs, ListProjectViewsResult, @@ -46,14 +51,11 @@ import { GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, isGitHubProjectRefInputTooLarge } from '../../shared/github-project-ref-input' +import { githubProjectHost } from '../../shared/github-project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. -export { - isValidOwnerSlug, - isValidRepoSlug, - isValidSlug, - classifyProjectError -} from './project-view/internals' +export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals' +export { classifyProjectError } from './project-view/project-error-classification' export { updateProjectItemFieldValue, clearProjectItemFieldValue, @@ -122,16 +124,32 @@ const parentFieldWarningLoggedByOwner = new Map() // Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. const parentFieldProbeInFlight = new Map>() -function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType): string { - return `${owner}\u0000${ownerType}` +// Why: GHES owners are a separate namespace and capability surface from +// github.com owners with the same login — scope cache keys by host so one +// host's probe result can't leak into another. Normalize github.com so +// host-less callers share the same probe state as explicitly pinned calls. +function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string { + const base = `${owner}\u0000${ownerType}` + return `${base}\u0000${githubProjectHost(host)}` } -function rememberOwnerType(owner: string, ownerType: GitHubProjectOwnerType | null): void { - rememberProjectViewCacheEntry(ownerTypeCache, owner, ownerType) +function ownerTypeCacheKey(owner: string, host?: string): string { + return `${owner}\u0000${githubProjectHost(host)}` } -function getCachedOwnerType(owner: string): GitHubProjectOwnerType | null | undefined { - return getProjectViewCacheEntry(ownerTypeCache, owner) +function rememberOwnerType( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) +} + +function getCachedOwnerType( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) } function markParentFieldRetried(scopeKey: string): void { @@ -174,16 +192,18 @@ export function _getProjectViewCacheSizesForTests(): { /** @internal - exposed for cache-bound tests only. */ export function _rememberProjectViewOwnerTypeForTests( owner: string, - ownerType: GitHubProjectOwnerType | null + ownerType: GitHubProjectOwnerType | null, + host?: string ): void { - rememberOwnerType(owner, ownerType) + rememberOwnerType(owner, ownerType, host) } /** @internal - exposed for cache-bound tests only. */ export function _getProjectViewOwnerTypeForTests( - owner: string + owner: string, + host?: string ): GitHubProjectOwnerType | null | undefined { - return getCachedOwnerType(owner) + return getCachedOwnerType(owner, host) } /** @internal - exposed for cache-bound tests only. */ @@ -618,6 +638,7 @@ async function fetchProjectViewsPage(args: { owner: string ownerType: GitHubProjectOwnerType projectNumber: number + host?: string after: string | null }): Promise< | { @@ -662,7 +683,8 @@ async function fetchProjectViewsPage(args: { } const res = await runGraphql>( query, - vars + vars, + projectGhExecOptions(args.host) ) if (!res.ok) { return res @@ -685,7 +707,8 @@ async function fetchProjectViewsPage(args: { async function fetchViewFieldsContinuation( viewId: string, - after: string + after: string, + host?: string ): Promise< { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } > { @@ -715,7 +738,7 @@ async function fetchViewFieldsContinuation( nodes?: (RawProjectV2Field | null)[] } } | null - }>(query, { viewId, after: cursor }) + }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) if (!res.ok) { return res } @@ -823,6 +846,7 @@ async function fetchItemsPageWithRaw(args: { first: number after: string | null includeParent: boolean + host?: string }): Promise< | { ok: true; page: RawItemsPage } | { @@ -832,6 +856,10 @@ async function fetchItemsPageWithRaw(args: { stderr: string } > { + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError, rawErrors: [], stderr: '' } + } const root = ownerQueryRoot(args.ownerType) const afterArg = args.after ? `, after: $after` : '' const afterVar = args.after ? `$after:String!, ` : '' @@ -864,7 +892,9 @@ async function fetchItemsPageWithRaw(args: { argsArr.push('-f', `after=${args.after}`) } - const guard = rateLimitGuard('graphql') + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard(args, 'graphql') if (guard.blocked) { return { ok: false, @@ -874,13 +904,16 @@ async function fetchItemsPageWithRaw(args: { } } await acquire() - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(args, 'graphql') try { let stdout = '' let stderr = '' let execFailed = false try { - const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' }) + const r = await ghExecFileAsync(argsArr, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) stdout = r.stdout stderr = r.stderr } catch (err) { @@ -897,7 +930,7 @@ async function fetchItemsPageWithRaw(args: { if (execFailed) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: [], stderr } @@ -913,7 +946,7 @@ async function fetchItemsPageWithRaw(args: { if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: [], stderr } @@ -921,7 +954,7 @@ async function fetchItemsPageWithRaw(args: { if (parsed.errors && parsed.errors.length > 0) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: parsed.errors, stderr } @@ -947,13 +980,14 @@ async function fetchAllItems(args: { ownerType: GitHubProjectOwnerType projectNumber: number query: string + host?: string }): Promise< | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } | { ok: false; error: GitHubProjectViewError; totalCount?: number } > { - // Why: keyed by (owner, ownerType) so one owner's missing Issue.parent capability doesn't poison unrelated owners. - const scopeKey = ownerScopeKey(args.owner, args.ownerType) - // Why: await any in-flight same-owner probe so we don't duplicate it, then re-read the retried flag (the probe may have flipped it). + // Why: isolate missing Issue.parent capability by owner, type, and host. + const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) + // Why: await the same-scope probe, then re-read state because it may have changed. const inFlight = parentFieldProbeInFlight.get(scopeKey) if (inFlight) { await inFlight.catch(() => {}) @@ -978,7 +1012,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent: true + includeParent: true, + host: args.host }) // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { @@ -999,7 +1034,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent + includeParent, + host: args.host }) } if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { @@ -1020,7 +1056,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent: false + includeParent: false, + host: args.host }) } if (!first.ok) { @@ -1087,7 +1124,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: cursor as string, - includeParent + includeParent, + host: args.host }) if (!next.ok) { return { ok: false, error: next.error, totalCount } @@ -1126,6 +1164,7 @@ async function fetchItemsCountOnly(args: { ownerType: GitHubProjectOwnerType projectNumber: number query: string + host?: string }): Promise { const root = ownerQueryRoot(args.ownerType) const query = ` @@ -1139,7 +1178,11 @@ async function fetchItemsCountOnly(args: { ` const res = await runGraphql< Record - >(query, { owner: args.owner, num: args.projectNumber, q: args.query }) + >( + query, + { owner: args.owner, num: args.projectNumber, q: args.query }, + projectGhExecOptions(args.host) + ) if (!res.ok) { return null } @@ -1178,6 +1221,7 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, + host: args.host, after: cursor }) if (!page.ok) { @@ -1225,7 +1269,7 @@ export async function getProjectViewTable( let extraFields: RawProjectV2Field[] = [] const fieldsPi = selectedRaw.fields?.pageInfo if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { - const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor) + const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host) if (!cont.ok) { return { ok: false, error: cont.error } } @@ -1248,7 +1292,8 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, - query: effectiveQuery + query: effectiveQuery, + host: args.host }) return { ok: false, @@ -1265,7 +1310,8 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, - query: effectiveQuery + query: effectiveQuery, + host: args.host }) if (!items.ok) { return { @@ -1278,6 +1324,7 @@ export async function getProjectViewTable( const table: GitHubProjectTable = { project: { id: project.id, + host: githubProjectHost(args.host), owner: args.owner, ownerType: args.ownerType, number: args.projectNumber, @@ -1320,7 +1367,10 @@ type RawViewerDiscovery = { } } -export async function listAccessibleProjects(): Promise { +export async function listAccessibleProjects( + args?: ListAccessibleProjectsArgs +): Promise { + const host = githubProjectHost(args?.host) const viewerProjects: GitHubProjectSummary[] = [] const orgProjects: GitHubProjectSummary[] = [] // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. @@ -1352,7 +1402,7 @@ export async function listAccessibleProjects(): Promise(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. return { ok: false, error: res.error } @@ -1373,6 +1423,7 @@ export async function listAccessibleProjects(): Promise(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). partialFailures.push({ owner: '*', message: res.error.message }) @@ -1434,8 +1485,8 @@ export async function listAccessibleProjects(): Promise= 1 - ? { viewNumber } - : {}) + host: url.host.toLowerCase(), + ...(viewNumber !== undefined ? { viewNumber } : {}) } + } catch { + // Shorthand parsing below remains available for non-URL input. } // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ @@ -1526,7 +1593,8 @@ export function parseProjectPaste(input: string): ParsedPaste | null { async function resolveOwnerType( owner: string, - preferred: GitHubProjectOwnerType | null + preferred: GitHubProjectOwnerType | null, + host?: string ): Promise< | { ok: true; ownerType: GitHubProjectOwnerType; title: string } | { ok: false; error: GitHubProjectViewError } @@ -1554,7 +1622,7 @@ async function resolveOwnerType( } const res = await runGraphql< Record - >(query, vars) + >(query, vars, projectGhExecOptions(host)) if (!res.ok) { return { ok: false, error: res.error } } @@ -1572,7 +1640,7 @@ async function resolveOwnerType( return { ok: true, title: '' } } - const cached = getCachedOwnerType(owner) + const cached = getCachedOwnerType(owner, host) const candidates: GitHubProjectOwnerType[] = preferred ? [preferred] : cached @@ -1590,7 +1658,7 @@ async function resolveOwnerType( for (const ot of ordered) { const r = await tryOne(ot, null) if (r.ok) { - rememberOwnerType(owner, ot) + rememberOwnerType(owner, ot, host) return { ok: true, ownerType: ot, title: r.title } } lastError = r.error @@ -1599,7 +1667,7 @@ async function resolveOwnerType( return { ok: false, error: r.error } } } - rememberOwnerType(owner, null) + rememberOwnerType(owner, null, host) return { ok: false, error: lastError ?? { type: 'not_found', message: 'Owner not found.' } @@ -1622,7 +1690,7 @@ export async function resolveProjectRef( error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } } } - const parsed = parseProjectPaste(input) + const parsed = parseProjectPaste(input, args.host) if (!parsed) { return { ok: false, @@ -1634,8 +1702,11 @@ export async function resolveProjectRef( } const preferred: GitHubProjectOwnerType | null = parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null + // Why: a pasted URL is authoritative. The ambient host only applies to + // owner/number shorthand; otherwise same-number Projects can cross hosts. + const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host // Verify by fetching project title. - const ownerRes = await resolveOwnerType(parsed.owner, preferred) + const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) if (!ownerRes.ok) { return { ok: false, error: ownerRes.error } } @@ -1648,7 +1719,7 @@ export async function resolveProjectRef( ` const res = await runGraphql< Record - >(query, { owner: parsed.owner, num: parsed.number }) + >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) if (!res.ok) { return { ok: false, error: res.error } } @@ -1662,7 +1733,8 @@ export async function resolveProjectRef( ownerType, number: parsed.number, title: p.title ?? '', - // Why: forward the view number from /views/{n} URLs so the renderer can skip the view-pick step (bare owner/number shorthand carries none). + host: executionHost, + // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined ? { viewNumber: parsed.viewNumber } : {}) @@ -1692,6 +1764,7 @@ export async function listProjectViews( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, + host: args.host, after: cursor }) if (!page.ok) { diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 586bbaa6e95..5952118ee4c 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -4,12 +4,60 @@ // translation, and a single hook point for future quota tracking. import { acquire, release } from '../gh-utils' import { extractExecError, ghExecFileAsync } from '../../git/runner' -import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' +import { + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, + type RateLimitBucketKind +} from '../rate-limit' import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' +import { isDefaultGitHubHost } from '../../../shared/github-repository-identity-key' +import { isGitHubHostAuthenticatedForGlobalCli } from '../github-enterprise-repository' +import { + classifyProjectError, + driftError, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-error-classification' -export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } +export { + acquire, + release, + extractExecError, + ghExecFileAsync, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend +} export type { RateLimitBucketKind } +/** gh exec routing shared by the project-view read/write paths. `host` pins + * GHES requests to the enterprise server via the runner's host qualifier. */ +export type ProjectGhExecOptions = { cwd?: string; host?: string } + +// Why: owner/project-addressed calls carry only a host (no repo slug), so they +// can't reuse githubHostExecOptions — mirror its host pinning for that shape. +export function projectGhExecOptions(host: string | undefined): ProjectGhExecOptions { + return { host: githubProjectHost(host) } +} + +export async function projectHostAuthenticationError( + host: string | undefined +): Promise { + const selectedHost = githubProjectHost(host) + if ( + isDefaultGitHubHost(selectedHost) || + (await isGitHubHostAuthenticatedForGlobalCli(selectedHost)) + ) { + return null + } + // Why: never target an unconfigured pasted host with gh; ambient + // GH_ENTERPRISE_TOKEN credentials could otherwise be sent to that server. + return { + type: 'auth_required', + message: `GitHub CLI is not authenticated to ${selectedHost}. Run gh auth login --hostname ${selectedHost}.` + } +} + // ─── Slug validation ────────────────────────────────────────────────── // Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names @@ -88,176 +136,6 @@ export function validateSlugArgs( return { ok: true } } -// ─── Error classification ────────────────────────────────────────────── - -export type GhGraphqlErrorShape = { - type?: string - message?: string - path?: (string | number)[] - extensions?: { code?: string } -} - -export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { - // `gh api graphql` prints the response JSON to stdout even on GraphQL - // errors, and the stderr carries a summary. Try stdout first; if parsing - // fails, fall back to stderr. - const sources = [stdout, stderr] - for (const src of sources) { - if (!src) { - continue - } - try { - const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } - if (parsed.errors && parsed.errors.length > 0) { - return parsed.errors - } - } catch { - // not JSON — continue - } - } - return [] -} - -export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { - const lower = stderr.toLowerCase() - // Preview-header shape: gh returns a 4xx with "preview" in the message. - if (lower.includes('preview') && lower.includes('parent')) { - return true - } - return errors.some((e) => { - const type = (e.type ?? '').toUpperCase() - if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { - const tail = e.path?.at(-1) - if (tail === 'parent') { - return true - } - // FIELD_ERRORS often omits `path`; match on message for the parent field. - if ((e.message ?? '').toLowerCase().includes('parent')) { - return true - } - } - return false - }) -} - -export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError { - const errors = extractGraphqlErrors(stderr, stdout) - const s = stderr.toLowerCase() - - // Auth - if ( - s.includes('authentication required') || - s.includes('not logged in') || - s.includes('gh auth login') - ) { - return { - type: 'auth_required', - message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.' - } - } - // Scope - if ( - s.includes('missing required scope') || - s.includes('your token has not been granted') || - (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) - ) { - return { - type: 'scope_missing', - message: - 'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.' - } - } - // Rate limit - if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { - return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } - } - // Network — checked BEFORE not_found because DNS failures surface as - // "could not resolve host", which would otherwise be partially matched by - // the not_found branch's "could not resolve" check. Substring matching here - // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" - // error always contains "to a", so we tighten the not_found check below to - // require that token. - if ( - s.includes('timeout') || - s.includes('no such host') || - s.includes('network') || - s.includes('could not resolve host') || - s.includes('dial tcp') - ) { - return { type: 'network_error', message: 'Network error — check your connection.' } - } - // Not found - if ( - s.includes('http 404') || - errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || - // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to - // an Organization") and "to a" otherwise. The previous singular-only - // check missed the "an" variants when gh emits only the stderr summary - // without a structured GraphQL error array. See bug-scan finding 3. - /could not resolve to an? /.test(s) - ) { - const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') - return { - type: 'not_found', - message: 'Project or view not found.', - details: firstNotFound - ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } - : undefined - } - } - // Validation - if (s.includes('http 422') || s.includes('validation failed')) { - return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } - } - // GraphQL error with structured info - if (errors.length > 0) { - const first = errors[0] - return { - type: 'unknown', - message: first.message ?? 'Unknown GraphQL error.', - details: { path: first.path, code: first.extensions?.code } - } - } - // Why: don't leak full stderr to the UI — it can include verbose request - // dumps with header diagnostics. Truncate to the first non-empty line and - // cap length so unexpected diagnostics stay readable but bounded. - const firstLine = - stderr - .split('\n') - .map((l) => l.trim()) - .find((l) => l.length > 0) ?? '' - const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine - return { - type: 'unknown', - message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' - } -} - -export function driftError( - reason: string, - details?: { path?: (string | number)[]; code?: string } -): GitHubProjectViewError { - return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } -} - -// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` -// when the cached snapshot says we're below the safety floor. Synthesize the -// same `rate_limited` error shape as the post-hoc classifier so the UI path -// is unchanged. We DO NOT fail open here when there's no cached snapshot — -// rateLimitGuard already handles that case (returns `blocked:false`). -export function rateLimitedError(blocked: { - remaining: number - limit: number - resetAt: number -}): GitHubProjectViewError { - const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) - const mins = Math.ceil(resetIn / 60) - return { - type: 'rate_limited', - message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` - } -} - // ─── Low-level gh api graphql invocation ─────────────────────────────── export type GraphqlVars = Record @@ -265,12 +143,18 @@ export type GraphqlVars = Record export async function runGraphql( query: string, vars: GraphqlVars, - cwd?: string + exec?: ProjectGhExecOptions ): Promise< | { ok: true; data: T } | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } > { - const guard = rateLimitGuard('graphql') + const authError = await projectHostAuthenticationError(exec?.host) + if (authError) { + return { ok: false, error: authError, raw: { stderr: '', stdout: '' } } + } + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard({ host: exec?.host }, 'graphql') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } } @@ -286,18 +170,19 @@ export async function runGraphql( } } await acquire() - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend({ host: exec?.host }, 'graphql') try { const { stdout, stderr } = await ghExecFileAsync(args, { encoding: 'utf-8', - ...(cwd ? { cwd } : {}) + ...(exec?.cwd ? { cwd: exec.cwd } : {}), + ...(exec?.host ? { host: exec.host } : {}) }) try { const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } if (parsed.errors && parsed.errors.length > 0) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, exec?.host), raw: { stderr, stdout } } } @@ -324,7 +209,7 @@ export async function runGraphql( const { stderr, stdout: maybeStdout } = extractExecError(err) return { ok: false, - error: classifyProjectError(stderr, maybeStdout), + error: classifyProjectError(stderr, maybeStdout, exec?.host), raw: { stderr, stdout: maybeStdout } } } finally { @@ -336,18 +221,25 @@ export async function runRest( args: string[], cwd?: string, bucket: RateLimitBucketKind = 'core', - options?: { expectEmpty?: boolean } + options?: { expectEmpty?: boolean; host?: string } ): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { - const guard = rateLimitGuard(bucket) + const authError = await projectHostAuthenticationError(options?.host) + if (authError) { + return { ok: false, error: authError } + } + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard({ host: options?.host }, bucket) if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() - noteRateLimitSpend(bucket) + noteRepositoryRateLimitSpend({ host: options?.host }, bucket) try { const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { encoding: 'utf-8', - ...(cwd ? { cwd } : {}) + ...(cwd ? { cwd } : {}), + ...(options?.host ? { host: options.host } : {}) }) // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no // body. Treat empty stdout as success rather than misclassifying the @@ -367,7 +259,7 @@ export async function runRest( } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + return { ok: false, error: classifyProjectError(stderr, maybeStdout, options?.host) } } finally { release() } diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 9d8cabca7f8..30a00a0f110 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -1,9 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({ +const { + acquireMock, + ghExecFileAsyncMock, + noteRepositoryRateLimitSpendMock, + projectHostAuthenticationErrorMock, + repositoryRateLimitGuardMock, + releaseMock, + runGraphqlMock, + runRestMock +} = vi.hoisted(() => ({ acquireMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), + projectHostAuthenticationErrorMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(), releaseMock: vi.fn(), + runGraphqlMock: vi.fn(), runRestMock: vi.fn() })) @@ -17,9 +30,13 @@ vi.mock('./internals', () => ({ ghExecFileAsync: ghExecFileAsyncMock, rateLimitGuard: () => ({ blocked: false }), noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + projectHostAuthenticationError: projectHostAuthenticationErrorMock, + projectGhExecOptions: (host?: string) => (host ? { host } : {}), classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), - runGraphql: vi.fn(), + runGraphql: runGraphqlMock, runRest: runRestMock, validateSlugArgs: (owner: string, repo: string) => owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } }, @@ -29,12 +46,15 @@ vi.mock('./internals', () => ({ : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } })) -import { updateIssueBySlug } from './mutations' +import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations' describe('updateIssueBySlug', () => { beforeEach(() => { acquireMock.mockReset().mockResolvedValue(undefined) ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + noteRepositoryRateLimitSpendMock.mockReset() + projectHostAuthenticationErrorMock.mockReset().mockResolvedValue(null) + repositoryRateLimitGuardMock.mockReset().mockReturnValue({ blocked: false }) releaseMock.mockReset() runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} }) }) @@ -60,16 +80,68 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(runRestMock).not.toHaveBeenCalled() }) + it('rejects an unconfigured Enterprise host before spawning gh', async () => { + projectHostAuthenticationErrorMock.mockResolvedValue({ + type: 'auth_required', + message: 'GitHub host is not authenticated' + }) + + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + host: 'evil.example.test', + number: 12, + updates: { state: 'closed' } + }) + ).resolves.toMatchObject({ ok: false, error: { type: 'auth_required' } }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('guards and accounts state mutations in the selected host quota scope', async () => { + const args = { + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example', + number: 12, + updates: { state: 'closed' as const } + } + + await expect(updateIssueBySlug(args)).resolves.toEqual({ ok: true }) + + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(args, 'core') + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith(args, 'core') + }) + + it('does not spawn a state mutation while that host quota is blocked', async () => { + repositoryRateLimitGuardMock.mockReturnValue({ blocked: true }) + + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example', + number: 12, + updates: { state: 'closed' } + }) + ).resolves.toMatchObject({ ok: false, error: { type: 'rate_limited' } }) + + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('closes slug-addressed duplicate issues with --duplicate-of', async () => { await expect( updateIssueBySlug({ @@ -82,7 +154,7 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(runRestMock).not.toHaveBeenCalled() }) @@ -115,7 +187,68 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'reopen', '12', '--repo', 'acme/widgets'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } + ) + }) + + it('threads the GHES host into gh exec and REST options', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + host: 'github.corp.example', + updates: { state: 'closed', stateReason: 'completed', title: 'New title' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], + { encoding: 'utf-8', host: 'github.corp.example' } + ) + expect(runRestMock).toHaveBeenCalledWith( + ['-X', 'PATCH', 'repos/acme/widgets/issues/12', '--raw-field', 'title=New title'], + undefined, + 'core', + { host: 'github.corp.example' } + ) + }) +}) + +describe('getWorkItemDetailsBySlug', () => { + beforeEach(() => { + runGraphqlMock.mockReset() + }) + + it('threads the GHES host into the GraphQL exec options', async () => { + runGraphqlMock.mockResolvedValue({ + ok: true, + data: { + repository: { + issue: { + id: 'I_1', + number: 7, + title: 'Enterprise issue', + url: 'https://github.corp.example/acme/widgets/issues/7', + state: 'OPEN' + } + } + } + }) + + const result = await getWorkItemDetailsBySlug({ + owner: 'acme', + repo: 'widgets', + number: 7, + type: 'issue', + host: 'github.corp.example' + }) + + expect(result.ok).toBe(true) + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.any(String), + { owner: 'acme', repo: 'widgets', num: 7 }, + { host: 'github.corp.example' } ) }) }) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index e2da3dae068..453a695baec 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -6,16 +6,18 @@ import { release, extractExecError, ghExecFileAsync, - rateLimitGuard, - noteRateLimitSpend, - classifyProjectError, - rateLimitedError, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, runGraphql, runRest, validateSlugArgs, assertPositiveInt, + projectHostAuthenticationError, + projectGhExecOptions, type GraphqlVars } from './internals' +import { classifyProjectError, rateLimitedError } from './project-error-classification' +import { githubProjectHost } from '../../../shared/github-project-identity' import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' import type { AddIssueCommentBySlugArgs, @@ -39,6 +41,10 @@ import type { UpdateProjectItemFieldArgs } from '../../../shared/github-project-types' +function githubHostExecOptions(args: { host?: string }): { host: string } { + return { host: githubProjectHost(args.host) } +} + // ─── Project field mutations ────────────────────────────────────────── class UnknownFieldMutationKindError extends Error { @@ -122,7 +128,7 @@ export async function updateProjectItemFieldValue( fieldId: args.fieldId, value: valVar.val } - const res = await runGraphql(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) if (!res.ok) { return { ok: false, error: res.error } } @@ -144,11 +150,15 @@ export async function clearProjectItemFieldValue( }) { projectV2Item { id } } } ` - const res = await runGraphql(query, { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId - }) + const res = await runGraphql( + query, + { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }, + projectGhExecOptions(args.host) + ) if (!res.ok) { return { ok: false, error: res.error } } @@ -207,6 +217,10 @@ export async function updateIssueBySlug( return { ok: false, error: duplicate.error } } } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. // State uses gh issue close/reopen so duplicate closes can record a target. @@ -214,6 +228,10 @@ export async function updateIssueBySlug( const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` if (state !== undefined) { + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } const stateArgs = state === 'closed' ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] @@ -228,11 +246,12 @@ export async function updateIssueBySlug( } } await acquire() + noteRepositoryRateLimitSpend(args, 'core') try { - await ghExecFileAsync(stateArgs, { encoding: 'utf-8' }) + await ghExecFileAsync(stateArgs, { encoding: 'utf-8', ...githubHostExecOptions(args) }) } catch (err) { const { stderr, stdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, stdout) } + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } } finally { release() } @@ -247,7 +266,7 @@ export async function updateIssueBySlug( if (body !== undefined) { patchArgs.push('--raw-field', `body=${body}`) } - const r = await runRest(patchArgs) + const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -263,7 +282,12 @@ export async function updateIssueBySlug( const addCount = addLabels?.length ?? 0 if (removeCount > 1) { type RawLabelResp = { name?: string }[] - const fetched = await runRest(['-X', 'GET', `${base}/labels`]) + const fetched = await runRest( + ['-X', 'GET', `${base}/labels`], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!fetched.ok) { return { ok: false, error: fetched.error } } @@ -282,7 +306,8 @@ export async function updateIssueBySlug( // dedicated DELETE endpoint is the documented way to remove all // labels in a single call. const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { - expectEmpty: true + expectEmpty: true, + ...githubHostExecOptions(args) }) if (!r.ok && r.error.type !== 'not_found') { return { ok: false, error: r.error } @@ -292,7 +317,7 @@ export async function updateIssueBySlug( for (const name of currentNames) { putArgs.push('--raw-field', `labels[]=${name}`) } - const r = await runRest(putArgs) + const r = await runRest(putArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -303,7 +328,7 @@ export async function updateIssueBySlug( for (const l of addLabels ?? []) { restArgs.push('--raw-field', `labels[]=${l}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -313,7 +338,7 @@ export async function updateIssueBySlug( ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], undefined, 'core', - { expectEmpty: true } + { expectEmpty: true, ...githubHostExecOptions(args) } ) if (!r.ok && r.error.type !== 'not_found') { return { ok: false, error: r.error } @@ -328,7 +353,7 @@ export async function updateIssueBySlug( for (const u of addAssignees) { restArgs.push('--raw-field', `assignees[]=${u}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -338,7 +363,7 @@ export async function updateIssueBySlug( for (const u of removeAssignees) { restArgs.push('--raw-field', `assignees[]=${u}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -384,7 +409,7 @@ export async function updatePullRequestBySlug( // No fields to update — nothing to do. return { ok: true } } - const r = await runRest(patchArgs) + const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -425,13 +450,18 @@ export async function addIssueCommentBySlug( if (typeof args.body !== 'string' || !args.body.trim()) { return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } } - const r = await runRest([ - '-X', - 'POST', - `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, - '--raw-field', - `body=${args.body}` - ]) + const r = await runRest( + [ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!r.ok) { return { ok: false, error: r.error } } @@ -452,13 +482,18 @@ export async function updateIssueCommentBySlug( if (typeof args.body !== 'string' || !args.body.trim()) { return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } } - const r = await runRest([ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, - '--raw-field', - `body=${args.body}` - ]) + const r = await runRest( + [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!r.ok) { return { ok: false, error: r.error } } @@ -480,7 +515,7 @@ export async function deleteIssueCommentBySlug( ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], undefined, 'core', - { expectEmpty: true } + { expectEmpty: true, ...githubHostExecOptions(args) } ) if (!r.ok) { return { ok: false, error: r.error } @@ -497,18 +532,22 @@ export async function listLabelsBySlug( if (!v.ok) { return v } - const guard = rateLimitGuard('core') + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() // Why: `--paginate` may fan out to multiple pages; we can only reasonably // estimate a 1-call spend up front. The next probe will reconcile. - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args, 'core') try { const { stdout } = await ghExecFileAsync( ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], - { encoding: 'utf-8' } + { encoding: 'utf-8', ...githubHostExecOptions(args) } ) return { ok: true, @@ -519,7 +558,7 @@ export async function listLabelsBySlug( } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } } finally { release() } @@ -532,15 +571,19 @@ export async function listAssignableUsersBySlug( if (!v.ok) { return v } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } // Seed logins merge after the fetch so callers can include currently-visible // assignees even if the repo participant search is sparse. const result: GitHubAssignableUser[] = [] - const guard = rateLimitGuard('core') + const guard = repositoryRateLimitGuard(args, 'core') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args, 'core') try { const { stdout } = await ghExecFileAsync( [ @@ -550,7 +593,7 @@ export async function listAssignableUsersBySlug( '--jq', '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' ], - { encoding: 'utf-8' } + { encoding: 'utf-8', ...githubHostExecOptions(args) } ) for (const line of stdout .trim() @@ -567,7 +610,7 @@ export async function listAssignableUsersBySlug( } } catch (err) { const { stderr } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, '') } + return { ok: false, error: classifyProjectError(stderr, '', args.host) } } finally { release() } @@ -614,7 +657,7 @@ export async function listIssueTypesBySlug( } | null)[] } | null } | null - }>(query, { owner: args.owner, repo: args.repo }) + }>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args)) if (!res.ok) { // Why: repos without issue types respond with a GraphQL error claiming the // `issueTypes` field is unknown. Map that to an empty list so the UI shows @@ -659,7 +702,8 @@ export async function updateIssueTypeBySlug( `query($owner:String!, $repo:String!, $num:Int!) { repository(owner:$owner, name:$repo) { issue(number:$num) { id } } }`, - { owner: args.owner, repo: args.repo, num: args.number } + { owner: args.owner, repo: args.repo, num: args.number }, + githubHostExecOptions(args) ) if (!lookup.ok) { return { ok: false, error: lookup.error } @@ -689,7 +733,7 @@ export async function updateIssueTypeBySlug( const vars: GraphqlVars = args.issueTypeId ? { issueId, issueTypeId: args.issueTypeId } : { issueId } - const res = await runGraphql(query, vars) + const res = await runGraphql(query, vars, githubHostExecOptions(args)) if (!res.ok) { return { ok: false, error: res.error } } @@ -810,7 +854,7 @@ export async function getWorkItemDetailsBySlug( }) | null } | null - }>(query, { owner: args.owner, repo: args.repo, num: args.number }) + }>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args)) if (!res.ok) { return { ok: false, error: res.error } } diff --git a/src/main/github/project-view/project-error-classification.ts b/src/main/github/project-view/project-error-classification.ts new file mode 100644 index 00000000000..1468552b345 --- /dev/null +++ b/src/main/github/project-view/project-error-classification.ts @@ -0,0 +1,177 @@ +// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the +// renderer can act on (auth vs scope vs rate limit vs drift), shared by the +// project-view read and mutation paths. +import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' + +export type GhGraphqlErrorShape = { + type?: string + message?: string + path?: (string | number)[] + extensions?: { code?: string } +} + +export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { + // `gh api graphql` prints the response JSON to stdout even on GraphQL + // errors, and the stderr carries a summary. Try stdout first; if parsing + // fails, fall back to stderr. + const sources = [stdout, stderr] + for (const src of sources) { + if (!src) { + continue + } + try { + const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return parsed.errors + } + } catch { + // not JSON — continue + } + } + return [] +} + +export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { + const lower = stderr.toLowerCase() + // Preview-header shape: gh returns a 4xx with "preview" in the message. + if (lower.includes('preview') && lower.includes('parent')) { + return true + } + return errors.some((e) => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { + const tail = e.path?.at(-1) + if (tail === 'parent') { + return true + } + // FIELD_ERRORS often omits `path`; match on message for the parent field. + if ((e.message ?? '').toLowerCase().includes('parent')) { + return true + } + } + return false + }) +} + +export function classifyProjectError( + stderr: string, + stdout: string, + host?: string +): GitHubProjectViewError { + const errors = extractGraphqlErrors(stderr, stdout) + const s = stderr.toLowerCase() + const selectedHost = githubProjectHost(host) + + // Auth + if ( + s.includes('authentication required') || + s.includes('not logged in') || + s.includes('gh auth login') + ) { + return { + type: 'auth_required', + message: `Sign in to GitHub to load project tasks. Run \`gh auth login --hostname ${selectedHost}\`.` + } + } + // Scope + if ( + s.includes('missing required scope') || + s.includes('your token has not been granted') || + (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) + ) { + return { + type: 'scope_missing', + message: `GitHub project access needs additional scopes. Run \`gh auth refresh --hostname ${selectedHost} -s project -s read:org -s repo\`.` + } + } + // Rate limit + if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { + return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } + } + // Network — checked BEFORE not_found because DNS failures surface as + // "could not resolve host", which would otherwise be partially matched by + // the not_found branch's "could not resolve" check. Substring matching here + // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" + // error always contains "to a", so we tighten the not_found check below to + // require that token. + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') || + s.includes('dial tcp') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + // Not found + if ( + s.includes('http 404') || + errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || + // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to + // an Organization") and "to a" otherwise. The previous singular-only + // check missed the "an" variants when gh emits only the stderr summary + // without a structured GraphQL error array. See bug-scan finding 3. + /could not resolve to an? /.test(s) + ) { + const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') + return { + type: 'not_found', + message: 'Project or view not found.', + details: firstNotFound + ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } + : undefined + } + } + // Validation + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } + } + // GraphQL error with structured info + if (errors.length > 0) { + const first = errors[0] + return { + type: 'unknown', + message: first.message ?? 'Unknown GraphQL error.', + details: { path: first.path, code: first.extensions?.code } + } + } + // Why: don't leak full stderr to the UI — it can include verbose request + // dumps with header diagnostics. Truncate to the first non-empty line and + // cap length so unexpected diagnostics stay readable but bounded. + const firstLine = + stderr + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine + return { + type: 'unknown', + message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' + } +} + +export function driftError( + reason: string, + details?: { path?: (string | number)[]; code?: string } +): GitHubProjectViewError { + return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } +} + +// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` +// when the cached snapshot says we're below the safety floor. Synthesize the +// same `rate_limited` error shape as the post-hoc classifier so the UI path +// is unchanged. We DO NOT fail open here when there's no cached snapshot — +// rateLimitGuard already handles that case (returns `blocked:false`). +export function rateLimitedError(blocked: { + remaining: number + limit: number + resetAt: number +}): GitHubProjectViewError { + const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) + const mins = Math.ceil(resetIn / 60) + return { + type: 'rate_limited', + message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` + } +} diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts index ab1373a70f0..323f729ea26 100644 --- a/src/main/github/rate-limit.test.ts +++ b/src/main/github/rate-limit.test.ts @@ -13,7 +13,21 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit' +import { + getRateLimit, + rateLimitGuard, + noteRateLimitSpend, + spendsSharedGitHubComQuota, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, + _resetRateLimitCache +} from './rate-limit' +import { + clearGhRateLimitBlock, + getGhRateLimitBlockedUntilMs, + notifyGhPrimaryRateLimit, + type GhRateLimitBucket +} from '../git/gh-rate-limit-breaker' const PAYLOAD = JSON.stringify({ resources: { @@ -49,12 +63,17 @@ describe('getRateLimit', () => { fetchedAt: 1_000 } }) + // The runner injects --hostname at spawn from the `host` option, so the + // probe passes bare argv and pins the host to github.com through options. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com' + }) }) it('caches a failed probe for the TTL instead of re-spawning gh', async () => { - // Why: regression for #7553 — GHES with rate limiting disabled fails every - // probe. Refreshes fail open past the failure, so without a negative cache - // each queued refresh would spawn its own doomed gh subprocess. + // Why: refreshes fail open past a probe failure, so without a negative cache + // an auth or transport failure would spawn the same doomed gh call repeatedly. ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) const first = await getRateLimit() @@ -136,3 +155,212 @@ describe('rateLimitGuard', () => { expect(rateLimitGuard('core')).toEqual({ blocked: false }) }) }) + +// Why: only default github.com traffic on the native runtime shares this +// snapshot's budget. GHES hosts and WSL distros run against separate quotas, +// so the shared guard must bypass them entirely. +describe('shared-quota host scoping', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, + search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, + graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } + } + }) + }) + expect((await getRateLimit()).ok).toBe(true) + } + + it('treats github.com / no host on the native runtime as sharing the budget', () => { + expect(spendsSharedGitHubComQuota(undefined)).toBe(true) + expect(spendsSharedGitHubComQuota(null)).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'github.com' })).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'GitHub.com' })).toBe(true) + }) + + it('excludes GHES hosts and WSL runtimes from the shared budget', () => { + expect(spendsSharedGitHubComQuota({ host: 'github.acme-corp.com' })).toBe(false) + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { wslDistro: 'Ubuntu' })).toBe(false) + expect(spendsSharedGitHubComQuota(undefined, { wslDistro: 'Ubuntu' })).toBe(false) + }) + + it('treats a WSL UNC cwd as non-shared even without an explicit wslDistro', () => { + // Why: the runner derives the WSL distro from a \\wsl.localhost\... cwd, so + // gh runs inside WSL and spends that distro's quota — not the native one. + expect( + spendsSharedGitHubComQuota( + { host: 'github.com' }, + { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } + ) + ).toBe(false) + expect( + spendsSharedGitHubComQuota( + { host: 'github.com' }, + { cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo' } + ) + ).toBe(false) + // Regular Windows/POSIX cwds keep sharing the native github.com budget. + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: 'C:\\repos\\a' })).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: '/Users/me/repo' })).toBe(true) + }) + + it('bypasses the guard and spend for a WSL UNC cwd', async () => { + await cacheExhaustedSnapshot(61) + + const cwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core', { cwd })).toEqual({ + blocked: false + }) + + noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core', 1, { cwd }) + const guard = rateLimitGuard('core') + expect(guard.blocked && guard.remaining).toBe(3) + }) + + it('applies the shared guard for github.com but bypasses non-shared scopes', async () => { + await cacheExhaustedSnapshot(61) + + expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core')).toEqual({ + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 61 + }) + expect(repositoryRateLimitGuard({ host: 'github.acme-corp.com' }, 'core')).toEqual({ + blocked: false + }) + expect( + repositoryRateLimitGuard({ host: 'github.com' }, 'core', { wslDistro: 'Ubuntu' }) + ).toEqual({ blocked: false }) + }) + + it('debits shared-budget spend but no-ops for non-shared scopes', async () => { + await cacheExhaustedSnapshot(61) + + noteRepositoryRateLimitSpend({ host: 'github.acme-corp.com' }, 'core') + const afterEnterprise = rateLimitGuard('core') + expect(afterEnterprise.blocked && afterEnterprise.remaining).toBe(3) + + noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core') + const afterShared = rateLimitGuard('core') + expect(afterShared.blocked && afterShared.remaining).toBe(2) + }) + + it('matches noteRateLimitSpend when the scope is shared github.com', async () => { + await cacheExhaustedSnapshot(61) + noteRateLimitSpend('core') + const guard = rateLimitGuard('core') + expect(guard.blocked && guard.remaining).toBe(2) + }) +}) + +// Why: the breaker's reset probe must query the tripping scope's runtime/host +// (WSL distro, GHES host), record into that scope only, and never touch the +// shared native-github.com snapshot. +describe('scope-aware breaker reset probe', () => { + const WSL_SCOPE = 'wsl:ubuntu:github.com' + const GHES_SCOPE = 'native:github.acme-corp.com' + const ALL_BUCKETS: GhRateLimitBucket[] = ['core', 'search', 'graphql'] + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + // Clear per-scope breaker state without unregistering the module-load + // probe (which _resetGhRateLimitBreaker would drop for later tests). + for (const scope of [WSL_SCOPE, GHES_SCOPE]) { + for (const bucket of ALL_BUCKETS) { + clearGhRateLimitBlock(bucket, scope) + } + } + vi.useRealTimers() + }) + + async function flushProbe(): Promise { + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + } + + it('refines a WSL-scope trip via a probe matching that scope', async () => { + // resetAt 601s > the 5-minute fallback so the refinement is observable. + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 0, reset: 601 }, + search: { limit: 30, remaining: 20, reset: 601 }, + graphql: { limit: 5000, remaining: 100, reset: 601 } + } + }) + }) + + notifyGhPrimaryRateLimit('core', WSL_SCOPE) + // Single-flight: a concurrent second trip must not spawn a second probe. + notifyGhPrimaryRateLimit('core', WSL_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com', + wslDistro: 'ubuntu' + }) + // The exhausted core bucket is refined to the real reset for THAT scope. + expect(getGhRateLimitBlockedUntilMs('core', 1_000, WSL_SCOPE)).toBe(601_000) + // Non-exhausted buckets in the same scope stay clear. + expect(getGhRateLimitBlockedUntilMs('search', 1_000, WSL_SCOPE)).toBeNull() + // The shared native-github.com snapshot was not written: the guard has no + // cached data and fails open. + expect(rateLimitGuard('core')).toEqual({ blocked: false }) + expect(getGhRateLimitBlockedUntilMs('core', 1_000)).toBeNull() + }) + + it('fails open on a GHES probe failure and negative-caches the scope', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) + + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + // A second trip inside the failure TTL must not spawn another probe. + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + // Fail open: the blunt fallback block stands (5 minutes from trip time). + expect(getGhRateLimitBlockedUntilMs('core', 1_000, GHES_SCOPE)).toBe(301_000) + + // Past the TTL the scope is probed again (and can recover on success). + vi.setSystemTime(1_000 + 30_000) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + resources: { core: { limit: 5000, remaining: 4999, reset: 700 } } + }) + }) + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.acme-corp.com' + }) + // Budget is actually available in that scope — the block is cleared. + expect(getGhRateLimitBlockedUntilMs('core', 31_000, GHES_SCOPE)).toBeNull() + }) +}) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 1b77198db68..2ca3f5cad43 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -9,11 +9,15 @@ import type { GitHubRateLimitBucket, GitHubRateLimitSnapshot } from '../../shared/types' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { isWslUncPath } from '../../shared/wsl-paths' import { acquire, release } from './gh-utils' import { ghExecFileAsync } from '../git/runner' import { clearGhRateLimitBlock, getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, + parseGhRateLimitScopeKey, recordGhPrimaryRateLimit, registerGhRateLimitResetProbe, type GhRateLimitBucket @@ -54,6 +58,8 @@ function parseBucket( export function _resetRateLimitCache(): void { cached = null probeFailure = null + scopeProbeFailureAtMs.clear() + scopeRefinementInFlight.clear() } // Circuit-breaker floors: enough budget for one user flow; search paginates by 1, so 2 leaves a safety click under the 30/min cap. @@ -120,6 +126,41 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } +// Why: this snapshot covers native github.com only; GHES and WSL use quotas protected by the runner's scoped breaker. +export function spendsSharedGitHubComQuota( + repository: { host?: string } | null | undefined, + executionOptions?: { wslDistro?: string; cwd?: string } +): boolean { + if (!isDefaultGitHubHost(repository?.host) || executionOptions?.wslDistro) { + return false + } + // Why: a \\wsl.localhost\... (or \\wsl$\...) cwd makes the runner spawn gh + // inside WSL, so it spends that distro's quota. The UNC shape is + // platform-unambiguous, so no win32 gate is needed. + return !(executionOptions?.cwd && isWslUncPath(executionOptions.cwd)) +} + +export function repositoryRateLimitGuard( + repository: { host?: string } | null | undefined, + bucket: RateLimitBucketKind, + executionOptions?: { wslDistro?: string; cwd?: string } +): ReturnType { + return spendsSharedGitHubComQuota(repository, executionOptions) + ? rateLimitGuard(bucket) + : { blocked: false } +} + +export function noteRepositoryRateLimitSpend( + repository: { host?: string } | null | undefined, + bucket: RateLimitBucketKind, + cost = 1, + executionOptions?: { wslDistro?: string; cwd?: string } +): void { + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + noteRateLimitSpend(bucket, cost) + } +} + // Why: the breaker only knows "blocked", not the reset time; one exempt probe refines it to the real reset or clears a stale block (single-flight so a 403 burst probes once). let resetRefinementInFlight: Promise | null = null @@ -147,7 +188,88 @@ function refineBreakerFromSnapshot(): void { })() } -registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot()) +const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com') +// Why: non-default scopes (GHES hosts, WSL runtimes) have their own quota and +// their own probe lifecycle — single-flight and failure negative-cache are +// keyed per scope so a GHES 403 storm can't starve a WSL refinement (or vice +// versa). +const scopeRefinementInFlight = new Map>() +const scopeProbeFailureAtMs = new Map() +const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512 + +function rememberScopeProbeFailure(scope: string, failedAt: number): void { + for (const [key, at] of scopeProbeFailureAtMs) { + if (failedAt - at >= RATE_LIMIT_CACHE_TTL_MS) { + scopeProbeFailureAtMs.delete(key) + } + } + scopeProbeFailureAtMs.delete(scope) + scopeProbeFailureAtMs.set(scope, failedAt) + while (scopeProbeFailureAtMs.size > SCOPE_PROBE_FAILURE_MAX_ENTRIES) { + const oldestKey = scopeProbeFailureAtMs.keys().next().value + if (oldestKey === undefined) { + break + } + scopeProbeFailureAtMs.delete(oldestKey) + } +} + +function refineBreakerForScope(scope: string): void { + // Default scope keeps the existing shared-snapshot refinement path. + if (scope === DEFAULT_BREAKER_SCOPE) { + refineBreakerFromSnapshot() + return + } + const parts = parseGhRateLimitScopeKey(scope) + if (!parts || scopeRefinementInFlight.has(scope)) { + return + } + // Why: GHES with rate limiting disabled 404s every probe. Fail open (the + // fallback block stands) and don't re-probe in a tight loop while the + // breaker keeps tripping. + const failedAt = scopeProbeFailureAtMs.get(scope) + if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) { + return + } + const probe = (async () => { + try { + await acquire() + try { + // Run the probe on the tripping scope's runtime/host so it describes + // that account's buckets, not the native github.com ones. + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { + encoding: 'utf-8', + host: parts.host, + ...(parts.runtime === 'wsl' ? { wslDistro: parts.wslDistro } : {}) + }) + const parsed = JSON.parse(stdout) as GhRateLimitPayload + scopeProbeFailureAtMs.delete(scope) + // Why: mirrors the default-scope refinement, but records into the + // per-scope breaker only — the shared snapshot must keep describing + // native github.com exclusively. + for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) { + const b = parseBucket(parsed.resources?.[bucket]) + if (b.limit > 0 && b.remaining <= 0) { + recordGhPrimaryRateLimit(bucket, b.resetAt * 1000, scope) + } else if (b.limit > 0) { + clearGhRateLimitBlock(bucket, scope) + } + } + } finally { + release() + } + } catch { + // Why: GHES hosts can be supplied through pasted project URLs. Bound the + // negative cache so repeated failing hosts cannot accumulate forever. + rememberScopeProbeFailure(scope, Date.now()) + } finally { + scopeRefinementInFlight.delete(scope) + } + })() + scopeRefinementInFlight.set(scope, probe) +} + +registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope)) // Why: single-flight so a concurrent fan-out resolves to one probe — the TTL cache can't dedupe calls that start before the first lands. let probeInFlight: Promise | null = null @@ -176,7 +298,12 @@ export async function getRateLimit(options?: { force?: boolean }): Promise { await acquire() try { - const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) + // Why: this singleton snapshot guards native github.com traffic. Pin the + // host so a process-level GH_HOST cannot make it describe a GHES account. + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com' + }) const parsed = JSON.parse(stdout) as GhRateLimitPayload const snapshot: GitHubRateLimitSnapshot = { core: parseBucket(parsed.resources?.core), diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts new file mode 100644 index 00000000000..3fdbacb6491 --- /dev/null +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + acquireMock, + releaseMock, + ghExecFileAsyncMock, + getWorkItemMock, + getPRCommentsMock, + getPRChecksMock +} = vi.hoisted(() => ({ + acquireMock: vi.fn<() => Promise>(), + releaseMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRCommentsMock: vi.fn(), + getPRChecksMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: acquireMock, + release: releaseMock, + ghExecFileAsync: ghExecFileAsyncMock, + ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), + githubRepoContext: (repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + }) +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getPRComments: getPRCommentsMock, + getPRChecks: getPRChecksMock +})) + +vi.mock('./github-api-repository', () => ({ + getIssueGitHubApiRepository: vi.fn(), + getOriginGitHubApiRepository: vi.fn(), + githubHostExecOptions: (repository?: { host?: string } | null) => + repository?.host ? { host: repository.host } : {}, + // Why: getWorkItemDetails awaits resolveGitHubRepoExecution and reads .ownerRepo. + resolveGitHubRepoExecution: vi.fn( + async ( + _repoPath: string, + repository?: { owner: string; repo: string; host?: string } | null + ) => ({ + ownerRepo: repository ?? { owner: 'acme', repo: 'widgets', host: 'github.com' }, + ghOptions: { + cwd: '/repo', + host: repository?.host ?? 'github.com' + } + }) + ) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn() +})) + +import { getWorkItemDetails } from './work-item-details' + +describe('getWorkItemDetails concurrency', () => { + beforeEach(() => { + let running = 0 + const queue: (() => void)[] = [] + acquireMock.mockReset() + acquireMock.mockImplementation(() => { + if (running < 4) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) + }) + releaseMock.mockReset() + releaseMock.mockImplementation(() => { + running -= 1 + queue.shift()?.() + }) + + getWorkItemMock.mockReset() + getWorkItemMock.mockImplementation(async (_repoPath: string, number: number) => ({ + id: `pr:${number}`, + type: 'pr', + number, + title: `PR ${number}`, + state: 'open', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: null, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + })) + + const withNestedPermit = async (value: T): Promise => { + await acquireMock() + try { + return value + } finally { + releaseMock() + } + } + getPRCommentsMock.mockReset() + getPRCommentsMock.mockImplementation(() => withNestedPermit([])) + getPRChecksMock.mockReset() + getPRChecksMock.mockImplementation(() => withNestedPermit([])) + + ghExecFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_id', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint.includes('/files?')) { + return { stdout: '[]' } + } + if (/\/pulls\/\d+$/.test(endpoint)) { + return { + stdout: JSON.stringify({ body: 'body', head: { sha: 'head' }, base: { sha: 'base' } }) + } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + }) + + it('does not deadlock four concurrent PR loads on nested client permits', async () => { + const details = await Promise.all( + [1, 2, 3, 4].map((number) => getWorkItemDetails('/repo', number, 'pr')) + ) + + expect(details.map((detail) => detail?.item.number)).toEqual([1, 2, 3, 4]) + expect(acquireMock.mock.calls.length).toBeGreaterThan(4) + expect(releaseMock).toHaveBeenCalledTimes(acquireMock.mock.calls.length) + }) +}) diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts new file mode 100644 index 00000000000..aca6e4a7c1c --- /dev/null +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, + getWorkItemMock, + getWorkItemByOwnerRepoMock, + getPRChecksMock, + getPRCommentsMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: 'core' | 'graphql' | 'search', + options?: { cwd?: string; host?: string; wslDistro?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: vi.fn(), + ghRepoExecOptions: vi.fn((context: { connectionId?: string | null; repoPath: string }) => + context.connectionId ? {} : { cwd: context.repoPath } + ), + githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + })), + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn() +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +import { getWorkItemDetails } from './work-item-details' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + +describe('getWorkItemDetails Enterprise host routing', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() + getPRChecksMock.mockReset() + getPRCommentsMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + }) + + it('uses the GitHub Enterprise host for SSH-backed issue work item details', async () => { + const enterpriseRepository = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:7', + type: 'issue', + number: 7, + title: 'Enterprise issue', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/issues/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'issue-author' + }) + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepository) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('comments(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Enterprise issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/issues/7/timeline?per_page=100&page=1') { + return { stdout: '' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1') + + expect(details?.body).toBe('Enterprise issue body') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', { + host: 'github.acme-corp.com' + }) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalled() + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + }) + + it('does not query the default host when an SSH issue work item is not found', async () => { + getWorkItemMock.mockResolvedValue(null) + + await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('uses the GitHub Enterprise host for SSH-backed PR work item details', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:7', + type: 'pr', + number: 7, + title: 'Enterprise PR files', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/pulls/7') { + return { + stdout: JSON.stringify({ + body: 'Enterprise PR body', + head: { sha: 'head-sha' }, + base: { sha: 'base-sha' } + }) + } + } + if (endpoint === 'repos/team/orca/pulls/7/files?per_page=100') { + return { + stdout: JSON.stringify([ + { + filename: 'src/enterprise.ts', + status: 'modified', + additions: 2, + deletions: 1, + changes: 3, + patch: '@@ -1 +1 @@' + } + ]) + } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_enterprise', + files: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ path: 'src/enterprise.ts', viewerViewedState: 'VIEWED' }] + } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') + + expect(details?.body).toBe('Enterprise PR body') + expect(details?.headSha).toBe('head-sha') + expect(details?.baseSha).toBe('base-sha') + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([ + { + path: 'src/enterprise.ts', + oldPath: undefined, + status: 'modified', + additions: 2, + deletions: 1, + isBinary: false, + reviewCommentLineNumbers: [], + viewerViewedState: 'VIEWED' + } + ]) + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + { prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } }, + 'ssh-1' + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + 'head-sha', + { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + undefined, + 'ssh-1' + ) + const apiCalls = ghExecFileAsyncMock.mock.calls + .map(([args]) => args as string[]) + .filter((args) => args[0] === 'api') + expect(apiCalls.length).toBeGreaterThan(0) + expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + }) + + it('does not query the default host when an SSH PR work item is not found', async () => { + getWorkItemMock.mockResolvedValue(null) + + await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('surfaces a found SSH PR without any default-host gh call when the repository is unresolved', async () => { + // Why: this is the regression under test — a *found* PR whose owner/repo + // cannot be resolved to an Enterprise host must not silently fall back to + // github.com. With no prRepo, origin resolution runs and returns null. + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:7', + type: 'pr', + number: 7, + title: 'Unresolved Enterprise PR', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author' + }) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') + + // The item is still surfaced, but no host-qualified (or default-host) gh + // request is made because the execution repository never resolved. + expect(details?.item.id).toBe('pr:7') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index a1ec6824aaa..6075803493c 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -13,6 +13,8 @@ const { getPRCommentsMock, rateLimitGuardMock, noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -24,13 +26,29 @@ const { getPRCommentsMock: vi.fn(), rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + options?: { cwd?: string; host?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: + vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + cost?: number, + options?: { cwd?: string; host?: string } + ) => void + >(), acquireMock: vi.fn(), releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ ghExecFileAsync: ghExecFileAsyncMock, - getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), githubRepoContext: vi.fn((repoPath, connectionId) => ({ @@ -43,17 +61,28 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client', () => ({ getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock })) vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('getWorkItemDetails PR file viewed state', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -65,6 +94,9 @@ describe('getWorkItemDetails PR file viewed state', () => { rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -162,10 +194,26 @@ describe('getWorkItemDetails PR file viewed state', () => { '/repo-root', 42, 'head-sha', - null, + { owner: 'stablyai', repo: 'orca', host: 'github.com' }, undefined, undefined ) + expect(repositoryRateLimitGuardMock.mock.calls.length).toBeGreaterThan(0) + expect( + repositoryRateLimitGuardMock.mock.calls.every(([, bucket, options]) => + bucket === 'graphql' + ? options?.cwd === '/repo-root' && options?.host === 'github.com' + : true + ) + ).toBe(true) + expect(noteRepositoryRateLimitSpendMock.mock.calls.length).toBeGreaterThan(0) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.every(([, bucket, , options]) => + bucket === 'graphql' + ? options?.cwd === '/repo-root' && options?.host === 'github.com' + : true + ) + ).toBe(true) }) it('keeps PR details loadable when checks lookup fails', async () => { diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts new file mode 100644 index 00000000000..cac3c33eaf4 --- /dev/null +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getEnterpriseGitHubRepoSlugMock, + getOwnerRepoMock, + getWorkItemMock, + getPRChecksMock, + getPRCommentsMock, + noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuardMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + noteRepositoryRateLimitSpendMock: + vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + cost?: number, + options?: { cwd?: string; host?: string } + ) => void + >(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + options?: { cwd?: string; host?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })) +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepoForRemote: getOwnerRepoMock, + getIssueOwnerRepo: vi.fn(), + ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + })), + acquire: vi.fn(), + release: vi.fn() +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: vi.fn(), + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +import { getPRFileContents, getWorkItemDetails } from './work-item-details' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + +function pullRequestItem(number: number, title: string): Record { + return { + id: `pr:${number}`, + type: 'pr', + number, + title, + state: 'open', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author' + } +} + +function auxiliaryPRResponse(args: string[]): { stdout: string } { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_file_list', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + return { stdout: JSON.stringify({ data: {} }) } +} + +describe('getWorkItemDetails PR file listing', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getWorkItemMock.mockReset() + getPRChecksMock.mockReset() + getPRChecksMock.mockResolvedValue([]) + getPRCommentsMock.mockReset() + getPRCommentsMock.mockResolvedValue([]) + noteRepositoryRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + }) + + it('loads files beyond the first 100-result REST page', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(108, 'Large PR')) + const restFile = (index: number) => ({ + filename: `src/file-${index}.ts`, + status: 'modified', + additions: 1, + deletions: 0, + changes: 1, + patch: '@@ -1 +1 @@' + }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/acme/widgets/pulls/108') { + return { stdout: JSON.stringify({ head: { sha: 'head' }, base: { sha: 'base' } }) } + } + if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100') { + return { + stdout: JSON.stringify(Array.from({ length: 100 }, (_, index) => restFile(index))) + } + } + if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100&page=2') { + return { stdout: JSON.stringify([restFile(100)]) } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 108, 'pr') + + expect(details?.files).toHaveLength(101) + expect(details?.files?.at(-1)?.path).toBe('src/file-100.ts') + const fileEndpoints = ghExecFileAsyncMock.mock.calls + .map(([args]) => (args as string[]).find((arg) => arg.includes('/files?'))) + .filter(Boolean) + expect(fileEndpoints).toEqual([ + 'repos/acme/widgets/pulls/108/files?per_page=100', + 'repos/acme/widgets/pulls/108/files?per_page=100&page=2' + ]) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(3) + expect( + repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(3) + }) + + // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; + // the Files tab keys its retry state off details.filesUnavailable. + it('flags filesUnavailable when the file fetch fails', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8305, 'Files fetch fails')) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8305') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') { + throw new Error('gh: API rate limit exceeded (403)') + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 8305, 'pr') + + expect(details?.filesUnavailable).toBe(true) + expect(details?.files).toBeUndefined() + }) + + it('preserves an empty file list as an available result', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8306, 'Empty PR')) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8306') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') { + return { stdout: '[]' } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 8306, 'pr') + + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([]) + }) + + it('backfills the Enterprise origin host before a host-less PR detail fan-out', async () => { + const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + getWorkItemMock.mockResolvedValueOnce({ + ...pullRequestItem(8, 'Host-less Enterprise PR'), + author: '', + prRepo: { owner: 'team', repo: 'orca' } + }) + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterprise) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/pulls/8') { + return { stdout: JSON.stringify({ body: 'Enterprise body' }) } + } + if (endpoint === 'repos/team/orca/pulls/8/files?per_page=100') { + return { stdout: '[]' } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/remote/repo', 8, 'pr', 'ssh-1') + + expect(details?.item.prRepo).toEqual(enterprise) + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + { prRepo: enterprise }, + 'ssh-1' + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + undefined, + enterprise, + undefined, + 'ssh-1' + ) + expect( + ghExecFileAsyncMock.mock.calls.every(([, options]) => options?.host === enterprise.host) + ).toBe(true) + }) + + it('does not run bare PR detail commands when local host resolution fails', async () => { + getWorkItemMock.mockResolvedValueOnce({ + ...pullRequestItem(9, 'Unresolved PR'), + prRepo: { owner: 'team', repo: 'orca' } + }) + getOwnerRepoMock.mockResolvedValue(null) + + const details = await getWorkItemDetails('/repo-root', 9, 'pr') + + expect(details).toMatchObject({ body: '', comments: [], checks: [], filesUnavailable: true }) + expect(getPRCommentsMock).not.toHaveBeenCalled() + expect(getPRChecksMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('uses the selected upstream GitHub Enterprise repo for both PR file sides', async () => { + const prRepo = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=base-sha') { + return { stdout: 'base content' } + } + if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=head-sha') { + return { stdout: 'head content' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo, + prNumber: 7, + path: 'src/path#with?chars.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toMatchObject({ + original: 'base content', + modified: 'head content', + originalIsBinary: false, + modifiedIsBinary: false + }) + const apiCalls = ghExecFileAsyncMock.mock.calls.map(([args]) => args as string[]) + expect(apiCalls).toHaveLength(2) + expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + expect( + repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(2) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(2) + }) + + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { + repositoryRateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: 1_800_000_000 + }) + + await expect( + getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/file.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + ).resolves.toMatchObject({ original: '', modified: '' }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 4fd21131c23..bc2bef2a722 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -7,12 +7,15 @@ type RateLimitGuardResult = const { ghExecFileAsyncMock, getOwnerRepoMock, - getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemoteMock, getWorkItemMock, + getWorkItemByOwnerRepoMock, getPRChecksMock, getPRCommentsMock, - rateLimitGuardMock, - noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, acquireMock, @@ -20,12 +23,21 @@ const { } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugForRemoteMock: vi.fn(), getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), getPRChecksMock: vi.fn(), getPRCommentsMock: vi.fn(), - rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: 'core' | 'graphql' | 'search', + options?: { cwd?: string; wslDistro?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => context.connectionId ? {} @@ -43,7 +55,7 @@ const { vi.mock('./gh-utils', () => ({ ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, ghRepoExecOptions: ghRepoExecOptionsMock, githubRepoContext: githubRepoContextMock, acquire: acquireMock, @@ -52,28 +64,48 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client', () => ({ getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugForRemoteMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('getWorkItemDetails', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() - getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugForRemoteMock.mockReset() + getEnterpriseGitHubRepoSlugForRemoteMock.mockResolvedValue(null) getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() getPRChecksMock.mockReset() getPRCommentsMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() @@ -93,7 +125,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const timelineEvents = [ { id: 101, @@ -214,6 +246,79 @@ describe('getWorkItemDetails', () => { } ]) expect(details?.participants?.[0]?.login).toBe('issue-author') + // Why: issue identities resolve hosted, pinned to github.com for dotcom. + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + { cwd: '/repo-root', host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + 1, + { cwd: '/repo-root', host: 'github.com' } + ) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + { cwd: '/repo-root', host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + 1, + { cwd: '/repo-root', host: 'github.com' } + ) + }) + + it('scopes collapsed issue GraphQL accounting to an implicit WSL UNC runtime', async () => { + const repository = { owner: 'acme', repo: 'widgets' } + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/acme/widgets/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author' + }) + getOwnerRepoForRemoteMock.mockResolvedValue(repository) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'WSL issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails(repoPath, 923, 'issue') + + expect(details?.body).toBe('WSL issue body') + // Why: issue identities resolve hosted, pinned to github.com for dotcom. + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { ...repository, host: 'github.com' }, + 'graphql', + { cwd: repoPath, host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { ...repository, host: 'github.com' }, + 'graphql', + 1, + { cwd: repoPath, host: 'github.com' } + ) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.cwd === repoPath)).toBe(true) }) it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { @@ -232,7 +337,7 @@ describe('getWorkItemDetails', () => { author: 'issue-author', assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -278,7 +383,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const makeTimelineEvent = (page: number, index: number, event: string): string => JSON.stringify({ id: `${page}:${index}`, @@ -354,7 +459,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) // Collapsed GraphQL throws → fallback path picks up. ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL error')) @@ -375,12 +480,12 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/acme/widgets/issues/923'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, @@ -392,18 +497,22 @@ describe('getWorkItemDetails', () => { '--jq', '.[] | @json' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(details?.body).toBe('Issue body') }) it('skips optional GraphQL issue detail calls when the cached GraphQL budget is low', async () => { - rateLimitGuardMock.mockReturnValue({ - blocked: true, - remaining: 3, - limit: 5000, - resetAt: 1_800_000_000 - }) + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'graphql' + ? { + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 1_800_000_000 + } + : { blocked: false } + ) getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', @@ -415,7 +524,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) }) .mockResolvedValueOnce({ stdout: '[]' }) @@ -425,7 +534,15 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) - expect(noteRateLimitSpendMock).not.toHaveBeenCalled() + expect( + noteRepositoryRateLimitSpendMock.mock.calls.some(([, bucket]) => bucket === 'graphql') + ).toBe(false) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + 2, + { cwd: '/repo-root', host: 'github.com' } + ) expect(details?.body).toBe('Issue body') expect(details?.participants).toEqual([]) }) @@ -442,7 +559,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -463,8 +580,13 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1') - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({}) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/home/tester/widgets', + 'upstream', + 'ssh-test-1', + {} + ) + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ host: 'github.com' }) expect(details?.body).toBe('Remote issue body') }) @@ -481,7 +603,8 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + // Why: PR detail resolution uses getOwnerRepoForRemote via origin repository lookup. + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -527,11 +650,16 @@ describe('getWorkItemDetails', () => { expect(details?.body).toBe('PR body') expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions) - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/repo-root', + 'origin', + null, + localGitOptions + ) expect(getPRCommentsMock).toHaveBeenCalledWith( '/repo-root', 42, - undefined, + { prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } }, null, localGitOptions ) @@ -539,7 +667,7 @@ describe('getWorkItemDetails', () => { '/repo-root', 42, 'head-sha', - null, + { owner: 'acme', repo: 'widgets', host: 'github.com' }, undefined, null, localGitOptions @@ -563,7 +691,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-07-11T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -597,7 +725,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-07-11T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -638,7 +766,7 @@ describe('getWorkItemDetails', () => { // A default `u/0` placeholder must be replaced by the resolved avatar. assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) const avatars: Record = { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 87cdbe52eaa..5e389be8581 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -15,14 +15,18 @@ import { ghExecFileAsync, acquire, release, - getOwnerRepo, - getIssueOwnerRepo, ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from './gh-utils' import { getWorkItem, getPRChecks, getPRComments } from './client' -import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { + getIssueGitHubApiRepository, + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' @@ -38,6 +42,10 @@ function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitEx return Object.keys(options).length > 0 ? [options] : [] } +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -120,8 +128,6 @@ type GraphQLIssueDetailsResponse = { errors?: { message?: string }[] } -type GitHubOwnerRepoSlug = { owner: string; repo: string } - type RestTimelineUser = { login?: string | null avatar_url?: string | null @@ -270,13 +276,17 @@ function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { } async function getIssueTimelineItems( - ownerRepo: GitHubOwnerRepoSlug, + ownerRepo: GitHubApiRepository, issueNumber: number, ghOptions: ReturnType ): Promise { try { const items: GitHubIssueTimelineItem[] = [] for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -286,7 +296,7 @@ async function getIssueTimelineItems( '--jq', '.[] | @json' ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. const pageEvents = parseRestTimelineEventLines(stdout) @@ -318,6 +328,7 @@ async function getIssueTimelineItems( async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ @@ -329,20 +340,18 @@ async function getIssueDetailsViaGraphQL( participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getIssueOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (!ownerRepo) { return null } - if (rateLimitGuard('graphql').blocked) { + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return null } try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -476,47 +485,43 @@ function isBinaryHint(file: RESTPRFile): boolean { return file.patch === undefined && file.changes > 0 } -async function getPRHeadBaseSha( +async function getPRMetadata( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise<{ headSha: string; baseSha: string } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!ownerRepo) { + // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted + // repository resolution fails, returning metadata for the wrong PR. + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return { body: '' } + } try { - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - head?: { sha?: string } - base?: { sha?: string } - } - if (data.head?.sha && data.base?.sha) { - return { headSha: data.head.sha, baseSha: data.base.sha } - } - return null - } + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'headRefOid,baseRefOid'], + ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], ghOptions ) const data = JSON.parse(stdout) as { - headRefOid?: string - baseRefOid?: string + body?: string | null + head?: { sha?: string } + base?: { sha?: string } } - if (data.headRefOid && data.baseRefOid) { - return { headSha: data.headRefOid, baseSha: data.baseRefOid } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) } - return null } catch { - return null + return { body: '' } } } @@ -524,30 +529,41 @@ async function getPRHeadBaseSha( async function getPRFiles( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) if (!ownerRepo) { return null } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100` - ], - ghOptions - ) - const data = JSON.parse(stdout) as RESTPRFile[] - return data.slice(0, MAX_PR_FILES).map((file) => ({ + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ path: file.filename, oldPath: file.previous_filename, status: mapFileStatus(file.status), @@ -569,19 +585,18 @@ type PRFileViewedStatesResult = { async function getPRFileViewedStates( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) if (!ownerRepo) { return null } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return null } const viewedStates = new Map() @@ -605,7 +620,7 @@ async function getPRFileViewedStates( if (after) { args.push('-f', `after=${after}`) } - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync(args, ghOptions) const parsed = JSON.parse(stdout) as { data?: { @@ -665,6 +680,7 @@ function mergePRFileViewedStates( async function getIssueBodyAndComments( repoPath: string, issueNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ @@ -673,14 +689,18 @@ async function getIssueBodyAndComments( assignees: string[] timelineItems: GitHubIssueTimelineItem[] }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getIssueOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } try { if (ownerRepo) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + // Why: the fallback starts the issue and comments reads together; debit + // both before spawning so a failed response cannot leave quota overstated. + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) const [issueResult, commentsResult, timelineItems] = await Promise.all([ ghExecFileAsync( [ @@ -727,6 +747,11 @@ async function getIssueBodyAndComments( const assignees = (issue.assignees ?? []).map((a) => a.login) return { body: issue.body ?? '', comments, assignees, timelineItems } } + if (connectionId) { + // Why: connection-backed gh has no cwd. A bare issue lookup could honor + // process GH_REPO/GH_HOST and return an unrelated repository's issue. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } // Fallback: non-GitHub remote const { stdout } = await ghExecFileAsync( ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], @@ -759,57 +784,27 @@ async function getIssueBodyAndComments( } } -async function getPRBody( - repoPath: string, - prNumber: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) - try { - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { body?: string | null } - return data.body ?? '' - } - const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'body'], - ghOptions - ) - const data = JSON.parse(stdout) as { body?: string } - return data.body ?? '' - } catch { - return '' - } -} - async function getWorkItemParticipants( repoPath: string, item: Pick, + resolvedRepository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: fork work items live upstream, so resolve owner/repo with the same upstream-first resolvers as body/comments (#7331). - const ownerRepo = - item.type === 'issue' - ? await getIssueOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) - : await getOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) + // Why: reuse the fan-out's repository identity so details cannot drift across hosts. + const ownerRepo = resolvedRepository if (!ownerRepo) { return [] } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return [] } try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -825,7 +820,7 @@ async function getWorkItemParticipants( '-F', `isPr=${item.type === 'pr'}` ], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + ghOptions ) const data = JSON.parse(stdout) as { data?: { @@ -858,16 +853,25 @@ async function getWorkItemParticipants( async function getGitHubUsersByLogin( repoPath: string, logins: string[], + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + if (!repository) { + return [] + } const uniqueLogins = Array.from( new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) ).slice(0, 40) if (uniqueLogins.length === 0) { return [] } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) + if (guard.blocked) { // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. console.warn( `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` @@ -881,10 +885,10 @@ async function getGitHubUsersByLogin( ) .join('\n') try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + ghOptions ) const data = JSON.parse(stdout) as { data?: Record< @@ -978,6 +982,7 @@ async function getMentionParticipants( >, comments: PRComment[], participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { @@ -993,6 +998,7 @@ async function getMentionParticipants( const graphQlUsers = await getGitHubUsersByLogin( repoPath, visibleLogins, + repository, connectionId, localGitOptions ) @@ -1003,15 +1009,19 @@ async function getPRChecksForDetails( repoPath: string, prNumber: number, headSha: string | undefined, + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + if (!repository) { + return [] + } try { return await getPRChecks( repoPath, prNumber, headSha, - null, + repository, undefined, connectionId, ...localGitOptionArgs(localGitOptions) @@ -1023,6 +1033,15 @@ async function getPRChecksForDetails( } } +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() + try { + return await operation() + } finally { + release() + } +} + export async function getWorkItemDetails( repoPath: string, number: number, @@ -1030,7 +1049,6 @@ export async function getWorkItemDetails( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: getWorkItem self-manages acquire/release; call it outside our semaphore so the cheap lookup doesn't block detail fetches. const item: Omit | null = await getWorkItem( repoPath, number, @@ -1042,13 +1060,19 @@ export async function getWorkItemDetails( return null } - await acquire() - try { - if (item.type === 'issue') { - // Why: one GraphQL round-trip returns everything (its participants include commenters, so getMentionParticipants is skipped); fall back to REST+GraphQL on any failure. + const resolvedRepository = + item.type === 'issue' + ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions) + : (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions)) + .ownerRepo + + if (item.type === 'issue') { + return withWorkItemDetailsPermit(async () => { + // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, + resolvedRepository, connectionId, localGitOptions ) @@ -1068,14 +1092,21 @@ export async function getWorkItemDetails( } // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ - getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions), - getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) + getIssueBodyAndComments( + repoPath, + item.number, + resolvedRepository, + connectionId, + localGitOptions + ), + getWorkItemParticipants(repoPath, item, resolvedRepository, connectionId, localGitOptions) ]) const mentionParticipants = await getMentionParticipants( repoPath, item, comments, participants, + resolvedRepository, connectionId, localGitOptions ) @@ -1087,45 +1118,81 @@ export async function getWorkItemDetails( participants: mentionParticipants, timelineItems } - } + }) + } - // PR: fetch body + comments + checks + files + head/base SHAs in parallel. - const [body, comments, shas, files, viewedStates, participants] = await Promise.all([ - getPRBody(repoPath, item.number, connectionId, localGitOptions), - getPRComments( - repoPath, - item.number, - undefined, - connectionId, - ...localGitOptionArgs(localGitOptions) + // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an + // outer permit while awaiting either can deadlock four concurrent detail loads. + const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ + Promise.all([ + withWorkItemDetailsPermit(() => + getPRMetadata(repoPath, item.number, resolvedRepository, connectionId, localGitOptions) ), - getPRHeadBaseSha(repoPath, item.number, connectionId, localGitOptions), - getPRFiles(repoPath, item.number, connectionId, localGitOptions), - getPRFileViewedStates(repoPath, item.number, connectionId, localGitOptions), - getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) - ]) + withWorkItemDetailsPermit(() => + getPRFiles(repoPath, item.number, resolvedRepository, connectionId, localGitOptions) + ), + withWorkItemDetailsPermit(() => + getPRFileViewedStates( + repoPath, + item.number, + resolvedRepository, + connectionId, + localGitOptions + ) + ), + withWorkItemDetailsPermit(() => + getWorkItemParticipants(repoPath, item, resolvedRepository, connectionId, localGitOptions) + ) + ]), + resolvedRepository + ? getPRComments( + repoPath, + item.number, + { prRepo: resolvedRepository }, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) + : Promise.resolve([]) + ]) - // Why: mention-author lookup and checks are independent, so run them in parallel. - const [mentionParticipants, checks] = await Promise.all([ - getMentionParticipants(repoPath, item, comments, participants, connectionId, localGitOptions), - getPRChecksForDetails(repoPath, item.number, shas?.headSha, connectionId, localGitOptions) - ]) + // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. + const [mentionParticipants, checks] = await Promise.all([ + withWorkItemDetailsPermit(() => + getMentionParticipants( + repoPath, + item, + comments, + participants, + resolvedRepository, + connectionId, + localGitOptions + ) + ), + getPRChecksForDetails( + repoPath, + item.number, + metadata.headSha, + resolvedRepository, + connectionId, + localGitOptions + ) + ]) - return { - item: enrichItemDisplayAvatars(item, mentionParticipants), - body, - comments, - headSha: shas?.headSha, - baseSha: shas?.baseSha, - pullRequestId: viewedStates?.pullRequestId, - checks, - // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." - files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), - filesUnavailable: files === null, - participants: mentionParticipants - } - } finally { - release() + return { + item: enrichItemDisplayAvatars( + resolvedRepository ? { ...item, prRepo: resolvedRepository } : item, + mentionParticipants + ), + body: metadata.body, + comments, + headSha: metadata.headSha, + baseSha: metadata.baseSha, + pullRequestId: viewedStates?.pullRequestId, + checks, + // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." + files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), + filesUnavailable: files === null, + participants: mentionParticipants } } @@ -1134,12 +1201,20 @@ async function fetchContentAtRef(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions - owner: string - repo: string + ownerRepo: GitHubApiRepository path: string ref: string }): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -1147,14 +1222,9 @@ async function fetchContentAtRef(args: { '300s', '-H', 'Accept: application/vnd.github.raw', - `repos/${args.owner}/${args.repo}/contents/${encodeURI(args.path)}?ref=${encodeURIComponent(args.ref)}` + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` ], - { - ...ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) - ), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } + ghOptions ) // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). const sample = stdout.slice(0, 2048) @@ -1174,6 +1244,7 @@ export async function getPRFileContents(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null prNumber: number path: string oldPath?: string @@ -1181,10 +1252,11 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const ownerRepo = await getOwnerRepo( + const { ownerRepo } = await resolveGitHubRepoExecution( args.repoPath, + args.prRepo, args.connectionId, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ) if (!ownerRepo) { return { @@ -1209,8 +1281,7 @@ export async function getPRFileContents(args: { repoPath: args.repoPath, connectionId: args.connectionId, localGitOptions: args.localGitOptions, - owner: ownerRepo.owner, - repo: ownerRepo.repo, + ownerRepo, path: originalPath, ref: originalRef }) @@ -1223,8 +1294,7 @@ export async function getPRFileContents(args: { repoPath: args.repoPath, connectionId: args.connectionId, localGitOptions: args.localGitOptions, - owner: ownerRepo.owner, - repo: ownerRepo.repo, + ownerRepo, path: args.path, ref: args.headSha }) diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 71f5d286624..bb1dd0cc1f6 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -856,7 +856,7 @@ describe('registerGitHubHandlers', () => { updatedAt: 0 } ] - const prRepo = { owner: 'acme', repo: 'orca' } + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme-corp.com' } const localGitOptions = { wslDistro: 'Ubuntu' } getWorkItemMock.mockResolvedValue(null) getWorkItemByOwnerRepoMock.mockResolvedValue(null) @@ -883,6 +883,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', owner: 'acme', repo: 'orca', + host: prRepo.host, number: 42, type: 'pr' }) @@ -894,6 +895,7 @@ describe('registerGitHubHandlers', () => { await handlers['gh:prFileContents'](null, { repoPath: '/workspace/repo', prNumber: 42, + prRepo, path: 'src/app.ts', status: 'modified', headSha: 'head-sha', @@ -923,13 +925,15 @@ describe('registerGitHubHandlers', () => { await handlers['gh:resolveReviewThread'](null, { repoPath: '/workspace/repo', threadId: 'thread-1', - resolve: true + resolve: true, + prRepo }) await handlers['gh:setPRFileViewed']( { sender: { id: 1 } }, { repoPath: '/workspace/repo', prNumber: 42, + prRepo, pullRequestId: 'PR_kw', path: 'src/app.ts', viewed: true @@ -940,12 +944,12 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, + prRepo, commentId: 11, body: ' Reply ', threadId: 'thread-1', path: 'src/app.ts', - line: 10, - prRepo + line: 10 } ) await handlers['gh:addPRReviewComment']( @@ -953,6 +957,7 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, + prRepo, commitId: ' head-sha ', path: 'src/app.ts', line: 10, @@ -992,21 +997,24 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, - updates: { state: 'closed' } + updates: { state: 'closed' }, + prRepo } ) await handlers['gh:rerunPRChecks'](null, { repoPath: '/workspace/repo', prNumber: 42, headSha: 'head-sha', - failedOnly: true + failedOnly: true, + prRepo }) await handlers['gh:requestPRReviewers']( { sender: { id: 1 } }, { repoPath: '/workspace/repo', prNumber: 42, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo } ) await handlers['gh:removePRReviewers']( @@ -1014,7 +1022,8 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo } ) @@ -1035,7 +1044,7 @@ describe('registerGitHubHandlers', () => { localGitOptions ) expect(getPRFileContentsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) ) expect(getPRChecksMock).toHaveBeenCalledWith( '/workspace/repo', @@ -1070,10 +1079,11 @@ describe('registerGitHubHandlers', () => { 'thread-1', true, null, + prRepo, localGitOptions ) expect(setPRFileViewedMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) ) expect(addPRReviewCommentReplyMock).toHaveBeenCalledWith( '/workspace/repo', @@ -1092,6 +1102,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', commitId: 'head-sha', body: 'Inline', + prRepo, localGitOptions }) ) @@ -1125,12 +1136,13 @@ describe('registerGitHubHandlers', () => { 42, { state: 'closed' }, null, + prRepo, localGitOptions ) expect(rerunPRChecksMock).toHaveBeenCalledWith( '/workspace/repo', 42, - { headSha: 'head-sha', failedOnly: true }, + { headSha: 'head-sha', failedOnly: true, prRepo }, null, localGitOptions ) @@ -1139,6 +1151,7 @@ describe('registerGitHubHandlers', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(removePRReviewersMock).toHaveBeenCalledWith( @@ -1146,6 +1159,7 @@ describe('registerGitHubHandlers', () => { 42, ['octo'], null, + prRepo, localGitOptions ) }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 84afec20c3b..a01225bfccf 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -93,6 +93,7 @@ import type { ClearProjectItemFieldArgs, DeleteIssueCommentBySlugArgs, GetProjectViewTableArgs, + ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, @@ -474,6 +475,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' } @@ -481,7 +483,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi const repo = assertRegisteredRepo(args, store) return getWorkItemByOwnerRepo( repo.path, - { owner: args.owner, repo: args.repo }, + // Why: Enterprise host identity must survive the IPC boundary or the + // lookup falls back to gh's default host for a same-named repo. + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, args.number, args.type, repoConnectionId(repo), @@ -539,6 +543,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -551,6 +556,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: repo.path, connectionId: repoConnectionId(repo), localGitOptions: localGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, prNumber: args.prNumber, path: args.path, oldPath: args.oldPath, @@ -671,6 +677,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null } ) => { const repo = assertRegisteredRepo(args, store) @@ -684,6 +691,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.threadId, args.resolve, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) } @@ -697,6 +705,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: string repoId?: string prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -717,6 +726,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: repo.path, connectionId: repoConnectionId(repo), localGitOptions: localGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId.trim(), path: args.path, viewed: Boolean(args.viewed) @@ -795,6 +805,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -833,6 +844,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } const result = await addPRReviewComment({ repoPath: repo.path, + prRepo: args.prRepo ?? null, prNumber: args.prNumber, commitId: args.commitId.trim(), path: args.path, @@ -947,7 +959,11 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:updatePRState', async ( event, - args: RepoScopedArgs & { prNumber: number; updates: GitHubPullRequestStateUpdate } + args: RepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -962,6 +978,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.prNumber, args.updates, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -978,7 +995,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:rerunPRChecks', async ( _event, - args: RepoScopedArgs & { prNumber: number; headSha?: string; failedOnly?: boolean } + args: RepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -991,7 +1013,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi return rerunPRChecks( repo.path, args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly }, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, repoConnectionId(repo), ...localGitOptionArgs(store, repo) ) @@ -1000,13 +1022,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:requestPRReviewers', - async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { + async ( + event, + args: RepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { const repo = assertRegisteredRepo(args, store) const result = await requestPRReviewers( repo.path, args.prNumber, args.reviewers, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -1021,13 +1051,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:removePRReviewers', - async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { + async ( + event, + args: RepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { const repo = assertRegisteredRepo(args, store) const result = await removePRReviewers( repo.path, args.prNumber, args.reviewers, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -1157,7 +1195,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi getRateLimit(args?.force ? { force: true } : undefined) ) - ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth()) + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) // ── GitHub ProjectV2 view handlers ───────────────────────────────── // Why: registered unconditionally so enabling the experimental flag at @@ -1165,7 +1205,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi // Handlers never throw across IPC — every failure mode resolves through the // GitHubProjectViewError envelope. - ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects()) + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => resolveProjectRef(args) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index d7786b7ca4e..61bbbc76ead 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -1881,6 +1881,61 @@ describe('repos:add + repos:clone', () => { expect(mockStore.addRepo).not.toHaveBeenCalled() }) + it('preserves the selected Enterprise host when aligning an existing folder', async () => { + const existing = { + id: 'repo-setup-enterprise', + path: '/tmp/from-setup-enterprise', + displayName: 'from-setup-enterprise', + kind: 'git', + badgeColor: '#22c55e' + } + const existingProject = { id: 'repo:repo-setup-enterprise', displayName: 'Existing' } + const selectedProject = { + id: 'github:github.acme-corp.com/acme/orca', + displayName: 'Enterprise project', + providerIdentity: { + provider: 'github', + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + } + } + const setup = { + id: existing.id, + projectId: existingProject.id, + repoId: existing.id, + hostId: 'local', + path: existing.path, + displayName: existing.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo' + } + let updatedRepo = existing + mockStore.getRepos.mockReturnValue([existing]) + mockStore.getProjects.mockReturnValue([existingProject, selectedProject]) + mockStore.getProjectHostSetups.mockReturnValue([setup]) + mockStore.updateRepo.mockImplementation((_repoId, updates) => { + updatedRepo = { ...updatedRepo, ...updates } + return updatedRepo + }) + + await handlers.get('projectHostSetups:setupExistingFolder')!(null, { + projectId: selectedProject.id, + hostId: 'local', + path: existing.path, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + + expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, { + upstream: { + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + } + }) + }) + it('prepares and invalidates roots when repos:update changes worktree base path', () => { const updated = { id: 'repo-update-root', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 54e440dba74..fd0ddcf770d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -143,7 +143,8 @@ function alignRepoWithRequestedProject( const updated = store.updateRepo(repo.id, { upstream: { owner: project.providerIdentity.owner, - repo: project.providerIdentity.repo + repo: project.providerIdentity.repo, + ...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {}) } }) if (!updated) { diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index e6abbe278f8..c491f344422 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -179,7 +179,8 @@ describe('detectRepoIcon', () => { source: 'github', label: 'stablyai/orca' }, - upstream: { owner: 'stablyai', repo: 'orca' } + // Why: fork parents resolve host-qualified so avatars/links stay on the fork's server. + upstream: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6599359557e..27cdc239baf 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5377,6 +5377,7 @@ describe('OrcaRuntimeService', () => { }) it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => { + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } getIssueMock.mockResolvedValueOnce({ number: 12, title: 'Remote issue' }) listGitHubIssuesMock.mockResolvedValueOnce({ items: [{ number: 7, title: 'Remote issue list item' }] @@ -5404,16 +5405,30 @@ describe('OrcaRuntimeService', () => { await expect(runtime.listRepoIssues('id:repo-1', 10)).resolves.toEqual([ { number: 7, title: 'Remote issue list item' } ]) - await expect(runtime.requestRepoPRReviewers('id:repo-1', 7, ['alex'])).resolves.toEqual({ - ok: true - }) - await expect(runtime.removeRepoPRReviewers('id:repo-1', 7, ['alex'])).resolves.toEqual({ + await expect(runtime.requestRepoPRReviewers('id:repo-1', 7, ['alex'], prRepo)).resolves.toEqual( + { + ok: true + } + ) + await expect(runtime.removeRepoPRReviewers('id:repo-1', 7, ['alex'], prRepo)).resolves.toEqual({ ok: true }) expect(getIssueMock).toHaveBeenCalledWith('/remote/repo', 12, 'ssh-1') expect(listGitHubIssuesMock).toHaveBeenCalledWith('/remote/repo', 10, undefined, 'ssh-1') - expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith('/remote/repo', 7, ['alex'], 'ssh-1') - expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith('/remote/repo', 7, ['alex'], 'ssh-1') + expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + ['alex'], + 'ssh-1', + prRepo + ) + expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + ['alex'], + 'ssh-1', + prRepo + ) }) it('routes runtime GitHub repo identity helpers through the selected WSL project runtime', async () => { @@ -5587,14 +5602,18 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const localGitOptions = { wslDistro: 'Ubuntu' } - const prRepo = { owner: 'acme', repo: 'orca' } + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') await runtime.getRepoPRChecks('id:repo-1', 42, 'head-sha', prRepo, { noCache: true }) - await runtime.rerunRepoPRChecks('id:repo-1', 42, { headSha: 'head-sha', failedOnly: true }) + await runtime.rerunRepoPRChecks('id:repo-1', 42, { + headSha: 'head-sha', + failedOnly: true, + prRepo + }) await runtime.getRepoPRCheckDetails('id:repo-1', { checkRunId: 9, workflowRunId: 8, @@ -5605,13 +5624,15 @@ describe('OrcaRuntimeService', () => { await runtime.getRepoPRComments('id:repo-1', 42, prRepo, { noCache: true }) await runtime.getRepoPRFileContents('id:repo-1', { prNumber: 42, + prRepo, path: 'src/app.ts', status: 'modified', headSha: 'head-sha', baseSha: 'base-sha' }) - await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true) + await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true, prRepo) await runtime.setRepoPRFileViewed('id:repo-1', { + prRepo, pullRequestId: 'PR_kw', path: 'src/app.ts', viewed: true @@ -5620,11 +5641,12 @@ describe('OrcaRuntimeService', () => { await runtime.updateRepoPRDetails('id:repo-1', 42, { body: 'New body' }, prRepo) await runtime.mergeRepoPR('id:repo-1', 42, 'squash', prRepo) await runtime.setRepoPRAutoMerge('id:repo-1', 42, true, 'squash', prRepo) - await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }) - await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo']) - await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo']) + await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }, prRepo) + await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo'], prRepo) + await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo'], prRepo) await runtime.addRepoPRReviewComment('id:repo-1', { prNumber: 42, + prRepo, body: 'Inline', commitId: 'head-sha', path: 'src/app.ts', @@ -5684,7 +5706,7 @@ describe('OrcaRuntimeService', () => { expect(rerunGitHubPRChecksMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, - { headSha: 'head-sha', failedOnly: true }, + { headSha: 'head-sha', failedOnly: true, prRepo }, null, localGitOptions ) @@ -5708,17 +5730,18 @@ describe('OrcaRuntimeService', () => { localGitOptions ) expect(getGitHubPRFileContentsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, prRepo }) ) expect(resolveGitHubReviewThreadMock).toHaveBeenCalledWith( TEST_REPO_PATH, 'thread-1', true, null, + prRepo, localGitOptions ) expect(setGitHubPRFileViewedMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, prRepo }) ) expect(updateGitHubPRTitleMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -5758,6 +5781,7 @@ describe('OrcaRuntimeService', () => { 42, { state: 'closed' }, null, + prRepo, localGitOptions ) expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( @@ -5765,6 +5789,7 @@ describe('OrcaRuntimeService', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( @@ -5772,12 +5797,14 @@ describe('OrcaRuntimeService', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(addGitHubPRReviewCommentMock).toHaveBeenCalledWith( expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, + prRepo, body: 'Inline' }) ) @@ -7656,7 +7683,7 @@ describe('OrcaRuntimeService', () => { kind: 'pr-link', link: { url: 'https://github.com/acme/orca/pull/42', - slug: { owner: 'acme', repo: 'orca' }, + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, number: 42 } }, @@ -7664,7 +7691,7 @@ describe('OrcaRuntimeService', () => { kind: 'pr-link', link: { url: 'https://github.com/acme/orca/pull/43', - slug: { owner: 'acme', repo: 'orca' }, + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, number: 43 } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 492d7abbb17..c7271fa7f74 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -619,6 +619,7 @@ import { import type { ClearProjectItemFieldArgs, GetProjectViewTableArgs, + ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, @@ -12851,7 +12852,10 @@ export class OrcaRuntimeService { const updated = this.store.updateRepo(repo.id, { upstream: { owner: existingProject.providerIdentity.owner, - repo: existingProject.providerIdentity.repo + repo: existingProject.providerIdentity.repo, + ...(existingProject.providerIdentity.host + ? { host: existingProject.providerIdentity.host } + : {}) } }) if (!updated) { @@ -13952,7 +13956,7 @@ export class OrcaRuntimeService { return isWslUncPath(scope.path) ? 'linux' : process.platform } - async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + async getRepoSlug(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options @@ -13960,7 +13964,7 @@ export class OrcaRuntimeService { : getRepoSlug(repo.path, repo.connectionId ?? null) } - async getRepoUpstream(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + async getRepoUpstream(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options @@ -13981,7 +13985,7 @@ export class OrcaRuntimeService { if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { continue } - let upstream: { owner: string; repo: string } | null + let upstream: GitHubOwnerRepo | null try { upstream = await getRepoUpstream(repo.path, null) } catch { @@ -14055,7 +14059,7 @@ export class OrcaRuntimeService { async getRepoWorkItemByOwnerRepo( repoSelector: string, - ownerRepo: { owner: string; repo: string }, + ownerRepo: { owner: string; repo: string; host?: string }, number: number, type: 'issue' | 'pr' ): Promise>> { @@ -14669,7 +14673,11 @@ export class OrcaRuntimeService { async rerunRepoPRChecks( repoSelector: string, prNumber: number, - options?: { headSha?: string; failedOnly?: boolean } + options?: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return rerunPRChecks( @@ -14720,6 +14728,7 @@ export class OrcaRuntimeService { repoSelector: string, args: { prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -14739,7 +14748,8 @@ export class OrcaRuntimeService { async resolveRepoReviewThread( repoSelector: string, threadId: string, - resolve: boolean + resolve: boolean, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return resolveReviewThread( @@ -14747,6 +14757,7 @@ export class OrcaRuntimeService { threadId, resolve, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14754,6 +14765,7 @@ export class OrcaRuntimeService { async setRepoPRFileViewed( repoSelector: string, args: { + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -14841,7 +14853,8 @@ export class OrcaRuntimeService { async updateRepoPRState( repoSelector: string, prNumber: number, - updates: GitHubPullRequestStateUpdate + updates: GitHubPullRequestStateUpdate, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updatePRState( @@ -14849,6 +14862,7 @@ export class OrcaRuntimeService { prNumber, updates, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14856,7 +14870,8 @@ export class OrcaRuntimeService { async requestRepoPRReviewers( repoSelector: string, prNumber: number, - reviewers: string[] + reviewers: string[], + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return requestPRReviewers( @@ -14864,6 +14879,7 @@ export class OrcaRuntimeService { prNumber, reviewers, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14871,7 +14887,8 @@ export class OrcaRuntimeService { async removeRepoPRReviewers( repoSelector: string, prNumber: number, - reviewers: string[] + reviewers: string[], + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return removePRReviewers( @@ -14879,6 +14896,7 @@ export class OrcaRuntimeService { prNumber, reviewers, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14973,8 +14991,10 @@ export class OrcaRuntimeService { ) } - async listGitHubProjects(): Promise>> { - return listAccessibleProjects() + async listGitHubProjects( + args?: ListAccessibleProjectsArgs + ): Promise>> { + return listAccessibleProjects(args) } async listGitHubLabelsBySlug( diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 48d014b0de9..74efa071a5f 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -111,7 +111,8 @@ const GitHubProjectRef = z .object({ owner: z.string(), ownerType: z.enum(['organization', 'user']), - number: z.number().int() + number: z.number().int(), + host: z.string().optional() }) .strict() const GitHubProjectSettings = z diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 44efc0d1ae2..1cf3be1337b 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -32,7 +32,14 @@ describe('client UI RPC methods', () => { minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', githubProjects: { - pinned: [], + pinned: [ + { + owner: 'stablyai', + ownerType: 'organization' as const, + number: 1, + host: 'ghe.example:8443' + } + ], recent: [], lastViewByProject: {}, activeProject: null @@ -68,7 +75,12 @@ describe('client UI RPC methods', () => { lastViewByProject: { 'organization:stablyai:1': { viewId: 'view-1' } }, - activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 } + activeProject: { + owner: 'stablyai', + ownerType: 'organization' as const, + number: 1, + host: 'ghe.example:8443' + } } } const runtime = { diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 2135507c3e9..06cc04d917e 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -175,7 +175,7 @@ describe('github RPC methods', () => { repo: 'repo-1', prNumber: 7, headSha: 'abc123', - prRepo: { owner: 'acme', repo: 'widgets' }, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, noCache: true }) ) @@ -184,7 +184,7 @@ describe('github RPC methods', () => { 'repo-1', 7, 'abc123', - { owner: 'acme', repo: 'widgets' }, + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, { noCache: true } @@ -235,6 +235,7 @@ describe('github RPC methods', () => { makeRequest('github.prFileContents', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, path: 'src/app.ts', status: 'modified', headSha: 'head', @@ -244,6 +245,7 @@ describe('github RPC methods', () => { expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', { prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, path: 'src/app.ts', oldPath: undefined, status: 'modified', @@ -264,11 +266,16 @@ describe('github RPC methods', () => { makeRequest('github.resolveReviewThread', { repo: 'repo-1', threadId: 'PRRT_1', - resolve: true + resolve: true, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) - expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true) + expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true, { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) expect(response).toMatchObject({ ok: true, result: true }) }) @@ -282,6 +289,7 @@ describe('github RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('github.setPRFileViewed', { repo: 'repo-1', + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, pullRequestId: 'PR_kwDO123', path: 'src/app.ts', viewed: true @@ -289,6 +297,7 @@ describe('github RPC methods', () => { ) expect(runtime.setRepoPRFileViewed).toHaveBeenCalledWith('repo-1', { + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, pullRequestId: 'PR_kwDO123', path: 'src/app.ts', viewed: true @@ -406,19 +415,29 @@ describe('github RPC methods', () => { makeRequest('github.requestPRReviewers', { repo: 'repo-1', prNumber: 7, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) const removeResponse = await dispatcher.dispatch( makeRequest('github.removePRReviewers', { repo: 'repo-1', prNumber: 7, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) - expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) - expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) + expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'], { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) + expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'], { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) expect(requestResponse).toMatchObject({ ok: true, result: { ok: true } }) expect(removeResponse).toMatchObject({ ok: true, result: { ok: true } }) }) @@ -434,11 +453,21 @@ describe('github RPC methods', () => { makeRequest('github.updatePRState', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, updates: { state: 'closed' } }) ) - expect(runtime.updateRepoPRState).toHaveBeenCalledWith('repo-1', 7, { state: 'closed' }) + expect(runtime.updateRepoPRState).toHaveBeenCalledWith( + 'repo-1', + 7, + { state: 'closed' }, + { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + } + ) expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) @@ -542,6 +571,7 @@ describe('github RPC methods', () => { makeRequest('github.addPRReviewComment', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, commitId: 'head', path: 'src/app.ts', line: 12, @@ -552,6 +582,7 @@ describe('github RPC methods', () => { expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', { prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, commitId: 'head', path: 'src/app.ts', line: 12, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 44375181da0..858d22b72bb 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -26,6 +26,9 @@ const WorkItem = RepoSelector.extend({ const WorkItemByOwnerRepo = RepoSelector.extend({ owner: requiredString('Missing owner'), ownerRepo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), type: z.enum(['issue', 'pr']) }) @@ -42,7 +45,10 @@ const RateLimit = z.object({ const SlugRepo = z.object({ owner: requiredString('Missing owner'), - repo: requiredString('Missing repo') + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString }) const SlugAssignableUsers = SlugRepo.extend({ @@ -86,6 +92,7 @@ const RerunPullRequestChecks = PullRequest.extend({ const PullRequestFileContents = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), path: requiredString('Missing file path'), oldPath: OptionalString, status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), @@ -94,12 +101,14 @@ const PullRequestFileContents = RepoSelector.extend({ }) const PullRequestFileViewed = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), pullRequestId: requiredString('Missing pull request ID'), path: requiredString('Missing file path'), viewed: z.boolean() }) const ReviewThread = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), threadId: requiredString('Missing thread ID'), resolve: z.boolean() }) @@ -134,6 +143,7 @@ const SetPrAutoMerge = RepoSelector.extend({ const UpdatePrState = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), updates: z.object({ state: z.enum(['open', 'closed']) }) @@ -141,11 +151,13 @@ const UpdatePrState = RepoSelector.extend({ const RequestPrReviewers = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), reviewers: z.array(z.string()).min(1) }) const RemovePrReviewers = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), reviewers: z.array(z.string()).min(1) }) @@ -180,6 +192,7 @@ const IssueComment = RepoSelector.extend({ const PRReviewComment = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), commitId: requiredString('Missing PR head SHA'), path: requiredString('File path required'), line: z.number().int().positive(), @@ -201,6 +214,9 @@ const ProjectOwnerType = z.enum(['organization', 'user']) const ProjectViewTable = z.object({ owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, ownerType: ProjectOwnerType, projectNumber: z.number().int().positive(), viewId: OptionalString, @@ -215,17 +231,26 @@ const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ }) const ProjectRef = z.object({ - input: requiredString('Missing project reference') + input: requiredString('Missing project reference'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString }) const ProjectViews = z.object({ owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, ownerType: ProjectOwnerType, projectNumber: z.number().int().positive() }) const ProjectItemField = z.object({ projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, itemId: requiredString('Missing item ID'), fieldId: requiredString('Missing field ID'), value: z.any() @@ -233,6 +258,9 @@ const ProjectItemField = z.object({ const ClearProjectItemField = z.object({ projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, itemId: requiredString('Missing item ID'), fieldId: requiredString('Missing field ID') }) @@ -240,6 +268,9 @@ const ClearProjectItemField = z.object({ const SlugIssueUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), updates: IssueUpdate }) @@ -247,6 +278,9 @@ const SlugIssueUpdate = z.object({ const SlugPullRequestUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), updates: z.object({ state: z.enum(['open', 'closed']).optional(), @@ -258,6 +292,9 @@ const SlugPullRequestUpdate = z.object({ const SlugIssueTypeUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), issueTypeId: z.string().nullable() }) @@ -265,6 +302,9 @@ const SlugIssueTypeUpdate = z.object({ const SlugIssueComment = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), body: requiredString('Comment body required') }) @@ -272,6 +312,9 @@ const SlugIssueComment = z.object({ const SlugIssueCommentEdit = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, commentId: z.number().int().positive(), body: requiredString('Comment body required') }) @@ -279,6 +322,9 @@ const SlugIssueCommentEdit = z.object({ const SlugIssueCommentDelete = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, commentId: z.number().int().positive() }) @@ -342,7 +388,11 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRepoWorkItemByOwnerRepo( params.repo, - { owner: params.owner, repo: params.ownerRepo }, + { + owner: params.owner, + repo: params.ownerRepo, + ...(params.host ? { host: params.host } : {}) + }, params.number, params.type ) @@ -397,7 +447,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.rerunRepoPRChecks(params.repo, params.prNumber, { headSha: params.headSha, - failedOnly: params.failedOnly + failedOnly: params.failedOnly, + prRepo: params.prRepo ?? null }) }), defineMethod({ @@ -414,6 +465,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRepoPRFileContents(params.repo, { prNumber: params.prNumber, + prRepo: params.prRepo ?? null, path: params.path, oldPath: params.oldPath, status: params.status, @@ -425,13 +477,19 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.resolveReviewThread', params: ReviewThread, handler: async (params, { runtime }) => - runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve) + runtime.resolveRepoReviewThread( + params.repo, + params.threadId, + params.resolve, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.setPRFileViewed', params: PullRequestFileViewed, handler: async (params, { runtime }) => runtime.setRepoPRFileViewed(params.repo, { + prRepo: params.prRepo ?? null, pullRequestId: params.pullRequestId, path: params.path, viewed: params.viewed @@ -476,19 +534,29 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.updatePRState', params: UpdatePrState, handler: async (params, { runtime }) => - runtime.updateRepoPRState(params.repo, params.prNumber, params.updates) + runtime.updateRepoPRState(params.repo, params.prNumber, params.updates, params.prRepo ?? null) }), defineMethod({ name: 'github.requestPRReviewers', params: RequestPrReviewers, handler: async (params, { runtime }) => - runtime.requestRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + runtime.requestRepoPRReviewers( + params.repo, + params.prNumber, + params.reviewers, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.removePRReviewers', params: RemovePrReviewers, handler: async (params, { runtime }) => - runtime.removeRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + runtime.removeRepoPRReviewers( + params.repo, + params.prNumber, + params.reviewers, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.createIssue', @@ -521,6 +589,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.addRepoPRReviewComment(params.repo, { prNumber: params.prNumber, + prRepo: params.prRepo ?? null, commitId: params.commitId, path: params.path, line: params.line, @@ -544,8 +613,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'github.project.listAccessible', - params: z.object({}), - handler: async (_params, { runtime }) => runtime.listGitHubProjects() + params: z.object({ host: OptionalString }), + handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ name: 'github.project.listLabelsBySlug', diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 7744bbe67eb..baa6ad47ee1 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -2361,7 +2361,8 @@ describe('OrcaRuntimeRpcServer', () => { path: 'src/app.ts', line: 10, startLine: undefined, - body: 'please fix' + body: 'please fix', + prRepo: null }) expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', { prNumber: 456, @@ -2378,19 +2379,22 @@ describe('OrcaRuntimeRpcServer', () => { oldPath: undefined, status: 'modified', headSha: 'abc123', - baseSha: 'def456' + baseSha: 'def456', + prRepo: null }) expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, { headSha: 'abc123', - failedOnly: true + failedOnly: true, + prRepo: null }) - expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true) + expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true, null) expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', { pullRequestId: 'PR_kw', path: 'src/app.ts', - viewed: true + viewed: true, + prRepo: null }) - expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex']) + expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex'], null) expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null) expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined) expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined) diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 95c5c9d2676..884b72f90de 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -81,6 +81,14 @@ import { getForgeProviderForRepository } from './forge-provider' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('forge provider interface', () => { beforeEach(() => { createGitHubPullRequestMock.mockReset() @@ -113,8 +121,9 @@ describe('forge provider interface', () => { // slug parsing returns null. Detection must claim it via the enterprise // resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN. getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 1a6ea4a4368..d4967486ca0 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -22,7 +22,6 @@ import { } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' -import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -126,25 +125,11 @@ function unwrapGitHubPRForBranchOutcome( const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: async (context) => { - const slug = await getRepoSlug( - context.repoPath, - context.connectionId, - ...hostedReviewExecutionArgs(context) - ) - if (slug) { - return slug - } - // Why: GHES remotes live on a custom host, so github.com-only slug parsing - // misses them and detection would otherwise fall through to Gitea (#8312). - // Claim the repo when gh is authenticated to its host — the same signal - // GitLab uses for self-hosted instances. - return getEnterpriseGitHubRepoSlug( - context.repoPath, - context.connectionId, - ...hostedReviewExecutionArgs(context) - ) - }, + // Why: getRepoSlug resolves hosted identities — GHES remotes are claimed when + // gh is authenticated to their host (the same signal GitLab uses for + // self-hosted instances), so detection never falls through to Gitea (#8312). + resolveRepository: async (context) => + getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 484ede6cbff..f2fa711ee54 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, @@ -155,10 +163,17 @@ function mockGitHubProvider(): void { // resolver claims the repo and reports the host for the gh auth probe (#8312). function mockGitHubEnterpriseProvider(): void { getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + // The auth gate still keys off the enterprise resolver (authed-GHES signal). getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca', diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 04c751d7c3b..cb593ae6a00 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview } from './hosted-review-creation' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, @@ -155,10 +163,17 @@ function mockGitHubProvider(): void { // resolver claims the repo and reports the host for the gh auth probe (#8312). function mockGitHubEnterpriseProvider(): void { getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + // The auth gate still keys off the enterprise resolver (authed-GHES signal). getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca', @@ -393,7 +408,7 @@ describe('createHostedReview', () => { ) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'github.com'], - { cwd: '/repo', wslDistro: 'Ubuntu' } + { cwd: '/repo', wslDistro: 'Ubuntu', host: 'github.com' } ) expect(createGitHubPullRequestMock).toHaveBeenCalledWith( '/repo', @@ -578,7 +593,7 @@ describe('createHostedReview', () => { expect(getUpstreamStatusMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'github.com'], - {} + { host: 'github.com' } ) expect(createGitHubPullRequestMock).toHaveBeenCalledWith( '/remote/repo', diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 9d95f6ddf02..618ef9bb684 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -68,9 +68,13 @@ async function isGitHubAuthenticated( } await acquire() try { + // Why: `host` scopes any rate-limit breaker trip to github.com — the host + // this probe actually targets — instead of a GH_HOST-derived scope. await ghExecFileAsync( ['auth', 'status', '--hostname', 'github.com'], - connectionId ? {} : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) } + connectionId + ? { host: 'github.com' } + : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), host: 'github.com' } ) return true } catch { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f10ba519bd9..d85797fdc1d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -263,6 +263,7 @@ import type { GetProjectViewTableResult, GitHubProjectCommentMutationResult, GitHubProjectMutationResult, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListAssignableUsersBySlugArgs, ListAssignableUsersBySlugResult, @@ -1489,11 +1490,11 @@ export type PreloadApi = { repoSlug: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> repoUpstream: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> prForBranch: (args: { repoPath: string repoId?: string @@ -1532,6 +1533,7 @@ export type PreloadApi = { repoId?: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' }) => Promise | null> @@ -1550,6 +1552,7 @@ export type PreloadApi = { prFileContents: ( args: GitHubRepoSelectorArgs & { prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -1603,6 +1606,7 @@ export type PreloadApi = { prNumber: number headSha?: string failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true; count: number } | { ok: false; error: string }> prComments: (args: { @@ -1619,10 +1623,12 @@ export type PreloadApi = { sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null }) => Promise setPRFileViewed: ( args: GitHubRepoSelectorArgs & { prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1654,18 +1660,21 @@ export type PreloadApi = { args: GitHubRepoSelectorArgs & { prNumber: number updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> requestPRReviewers: ( args: GitHubRepoSelectorArgs & { prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> removePRReviewers: ( args: GitHubRepoSelectorArgs & { prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> updateIssue: ( @@ -1728,9 +1737,11 @@ export type PreloadApi = { */ rateLimit: (args?: { force?: boolean }) => Promise /** Explains scope_missing ProjectV2 failures — notably a shell `GITHUB_TOKEN` shadowing the keyring credential, where `gh auth refresh` is a no-op. */ - diagnoseAuth: () => Promise + diagnoseAuth: (args?: { host?: string }) => Promise // ── ProjectV2 (GitHub Projects) ───────────────────────────────── - listAccessibleProjects: () => Promise + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ) => Promise resolveProjectRef: (args: ResolveProjectRefArgs) => Promise listProjectViews: (args: ListProjectViewsArgs) => Promise getProjectViewTable: (args: GetProjectViewTableArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0021f55dfa7..04181c3b812 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,7 @@ import type { GitHubAssignableUser, GitHubCommentResult, GitHubCreateIssueResult, + GitHubOwnerRepo, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -113,6 +114,7 @@ import type { GetProjectViewTableResult, GitHubProjectCommentMutationResult, GitHubProjectMutationResult, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListAssignableUsersBySlugArgs, ListAssignableUsersBySlugResult, @@ -1199,6 +1201,7 @@ const api = { repoId?: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), @@ -1223,6 +1226,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: string @@ -1265,7 +1269,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number headSha?: string - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prChecks', args), @@ -1277,7 +1281,7 @@ const api = { workflowRunId?: number checkName?: string url?: string | null - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { @@ -1287,6 +1291,7 @@ const api = { prNumber: number headSha?: string failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => ipcRenderer.invoke('gh:rerunPRChecks', args), @@ -1295,7 +1300,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), @@ -1305,6 +1310,7 @@ const api = { sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { @@ -1312,6 +1318,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1322,7 +1329,7 @@ const api = { repoId?: string prNumber: number title: string - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), mergePR: (args: { @@ -1331,7 +1338,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), @@ -1342,7 +1349,7 @@ const api = { prNumber: number enabled: boolean method?: 'merge' | 'squash' | 'rebase' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:setPRAutoMerge', args), @@ -1352,6 +1359,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:updatePRState', args), @@ -1361,6 +1369,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:requestPRReviewers', args), @@ -1370,6 +1379,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:removePRReviewers', args), @@ -1389,7 +1399,7 @@ const api = { number: number body: string type?: 'issue' | 'pr' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { @@ -1402,7 +1412,7 @@ const api = { threadId?: string path?: string line?: number - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { @@ -1410,6 +1420,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -1454,11 +1465,14 @@ const api = { rateLimit: (args?: { force?: boolean }): Promise => ipcRenderer.invoke('gh:rateLimit', args), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gh:diagnoseAuth'), + diagnoseAuth: (args?: { host?: string }): Promise => + ipcRenderer.invoke('gh:diagnoseAuth', args), // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── - listAccessibleProjects: (): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects'), + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ): Promise => + ipcRenderer.invoke('gh:listAccessibleProjects', args), resolveProjectRef: (args: ResolveProjectRefArgs): Promise => ipcRenderer.invoke('gh:resolveProjectRef', args), listProjectViews: (args: ListProjectViewsArgs): Promise => diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 30c4a491bd4..815c8dac7e4 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -187,6 +187,8 @@ import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods } from '../../../shared/github-pr-merge-methods' +import { githubProjectHost } from '../../../shared/github-project-identity' +import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { findGithubIssueWorkspaceAttachment, getGithubWorkItemWorkspaceAttachmentLabel @@ -227,18 +229,18 @@ import { type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' -// Why: the dialog lacks owner/repo context, so recover it from the canonical URL present on every item; non-GitHub hosts return null (matches the indicator's suppression rule). +// Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { try { const parsed = new URL(url) - if (parsed.hostname !== 'github.com') { + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) { return null } const segments = parsed.pathname.split('/').filter(Boolean) if (segments.length < 2) { return null } - return { owner: segments[0], repo: segments[1] } + return { owner: segments[0], repo: segments[1], host: parsed.host } } catch { return null } @@ -297,6 +299,8 @@ function normalizeItemDialogTab( export type GitHubItemDialogProjectOrigin = { owner: string repo: string + /** GitHub host (e.g. GHES); absent means github.com. */ + host?: string number: number type: 'issue' | 'pr' projectId: string @@ -304,6 +308,25 @@ export type GitHubItemDialogProjectOrigin = { cacheKey: string } +// Why: every PR mutation needs the same host-pinned identity so process GH_HOST +// cannot redirect a github.com item or a Project row to the wrong server. +function resolvePullRequestRepo( + item: Pick, + projectOrigin?: Pick +): GitHubOwnerRepo | null { + const repo = + item.prRepo ?? + (projectOrigin + ? { + owner: projectOrigin.owner, + repo: projectOrigin.repo, + host: projectOrigin.host + } + : null) ?? + parseOwnerRepoFromItemUrl(item.url) + return repo ? { ...repo, host: githubProjectHost(repo.host) } : null +} + type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null @@ -512,7 +535,8 @@ function PRAssigneesPanel({ slugOwner, slugRepo, assigneeLogins, - sourceSettings + sourceSettings, + projectOrigin?.host ?? assigneeSlug?.host ) const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath @@ -683,12 +707,14 @@ function PRReviewersPanel({ loading, repoPath, sourceContext, + projectOrigin, onReviewersRequested }: { item: GitHubWorkItem loading: boolean repoPath: string | null sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const [open, setOpen] = useState(false) @@ -788,19 +814,23 @@ function PRReviewersPanel({ return Array.from(byLogin.values()) }, [item.author, item.latestReviews, localReviewRequests]) - const reviewSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const reviewRepo = useMemo( + () => resolvePullRequestRepo(item, projectOrigin), + [item, projectOrigin] + ) const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewSlug ? reviewSlug.owner : null, - open && reviewSlug ? reviewSlug.repo : null, + open && reviewRepo ? reviewRepo.owner : null, + open && reviewRepo ? reviewRepo.repo : null, reviewerSeedUsers.map((user) => user.login), - sourceSettings + sourceSettings, + reviewRepo?.host ) const reviewerMetadataByPath = useRepoAssignees( - open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null, + open && !reviewRepo ? repoPath : null, + open && !reviewRepo ? item.repoId : null, sourceSettings ) - const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath + const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } const reviewers = getGitHubPRReviewerRows(displayItem) const authorLogin = item.author?.toLowerCase() ?? null @@ -930,7 +960,12 @@ function PRReviewersPanel({ ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.requestPRReviewers', - { repo: runtimeRepo, prNumber: item.number, reviewers: logins }, + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, { timeoutMs: 30_000 } ) : await window.api.gh.requestPRReviewers({ @@ -938,7 +973,8 @@ function PRReviewersPanel({ repoId: item.repoId, sourceContext, prNumber: item.number, - reviewers: logins + reviewers: logins, + prRepo: reviewRepo }) if (!reviewerPanelMountedRef.current) { return @@ -1021,7 +1057,12 @@ function PRReviewersPanel({ ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.removePRReviewers', - { repo: runtimeRepo, prNumber: item.number, reviewers: logins }, + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, { timeoutMs: 30_000 } ) : await window.api.gh.removePRReviewers({ @@ -1029,7 +1070,8 @@ function PRReviewersPanel({ repoId: item.repoId, sourceContext, prNumber: item.number, - reviewers: logins + reviewers: logins, + prRepo: reviewRepo }) if (!reviewerPanelMountedRef.current) { return @@ -1646,6 +1688,7 @@ function getPRFileContentCacheKey(args: { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null file: GitHubPRFile headSha: string baseSha: string @@ -1659,6 +1702,7 @@ function getPRFileContentCacheKey(args: { repositoryKey, sourceKey, args.prNumber, + args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', args.file.path, args.file.oldPath ?? '', args.file.status, @@ -1672,6 +1716,7 @@ function loadPRFileContents(args: { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null file: GitHubPRFile headSha: string baseSha: string @@ -1692,6 +1737,7 @@ function loadPRFileContents(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, path: args.file.path, oldPath: args.file.oldPath, status: args.file.status, @@ -1705,6 +1751,7 @@ function loadPRFileContents(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, path: args.file.path, oldPath: args.file.oldPath, status: args.file.status, @@ -1737,6 +1784,7 @@ function addIssueCommentForRepo(args: { number: number body: string type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null }): Promise>> { const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) if (runtimeHost) { @@ -1746,7 +1794,8 @@ function addIssueCommentForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), number: args.number, - body: args.body + body: args.body, + prRepo: args.prRepo ?? null }, { timeoutMs: 30_000 } ).then((result) => { @@ -1771,7 +1820,8 @@ function addIssueCommentForRepo(args: { sourceContext: args.sourceContext, number: args.number, body: args.body, - type: args.type + type: args.type, + prRepo: args.prRepo ?? null }) } @@ -1780,6 +1830,7 @@ function addPRReviewCommentForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -1794,6 +1845,7 @@ function addPRReviewCommentForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commitId: args.commitId, path: args.path, line: args.line, @@ -1822,6 +1874,7 @@ function addPRReviewCommentForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commitId: args.commitId, path: args.path, line: args.line, @@ -1835,6 +1888,7 @@ function addPRReviewCommentReplyForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commentId: number body: string threadId?: string @@ -1849,6 +1903,7 @@ function addPRReviewCommentReplyForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commentId: args.commentId, body: args.body, threadId: args.threadId, @@ -1877,6 +1932,7 @@ function addPRReviewCommentReplyForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commentId: args.commentId, body: args.body, threadId: args.threadId, @@ -1913,6 +1969,7 @@ function setPRFileViewedForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1924,6 +1981,7 @@ function setPRFileViewedForRepo(args: { 'github.setPRFileViewed', { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId, path: args.path, viewed: args.viewed @@ -1950,6 +2008,7 @@ function setPRFileViewedForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId, path: args.path, viewed: args.viewed @@ -2109,6 +2168,7 @@ type PRFilesCombinedDiffViewerProps = { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null prUrl: string headSha: string | undefined baseSha: string | undefined @@ -2124,6 +2184,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, prUrl, headSha, baseSha, @@ -2200,11 +2261,12 @@ function PRFilesCombinedDiffViewer({ JSON.stringify({ repoId, prNumber, + prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, headSha: headSha ?? null, baseSha: baseSha ?? null, files: diffEntrySignature }), - [baseSha, diffEntrySignature, headSha, prNumber, repoId] + [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] ) const [sections, setSections] = useState([]) const [sideBySide, setSideBySide] = useState(false) @@ -2298,6 +2360,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, file, headSha, baseSha @@ -2346,7 +2409,7 @@ function PRFilesCombinedDiffViewer({ ) }) }, - [baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext] + [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] ) const retrySection = useCallback( @@ -2491,6 +2554,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, commitId: headSha, path: section.path, line: lineNumber, @@ -2513,7 +2577,7 @@ function PRFilesCombinedDiffViewer({ ) return true }, - [headSha, onCommentAdded, prNumber, repoId, repoPath, sourceContext] + [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] ) const renderViewedCheckbox = useCallback( @@ -2661,6 +2725,7 @@ function CommentCodeContext({ repoId, sourceContext, prNumber, + prRepo, files, headSha, baseSha @@ -2670,6 +2735,7 @@ function CommentCodeContext({ repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null files: GitHubPRFile[] headSha: string | undefined baseSha: string | undefined @@ -2693,7 +2759,16 @@ function CommentCodeContext({ return } let cancelled = false - loadPRFileContents({ repoPath, repoId, sourceContext, prNumber, file, headSha, baseSha }) + loadPRFileContents({ + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + file, + headSha, + baseSha + }) .then((result) => { if (!cancelled) { setContents(result) @@ -2707,7 +2782,7 @@ function CommentCodeContext({ return () => { cancelled = true } - }, [baseSha, file, headSha, line, prNumber, repoId, repoPath, sourceContext]) + }, [baseSha, file, headSha, line, prNumber, prRepo, repoId, repoPath, sourceContext]) const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( contextExpansionState, @@ -3098,6 +3173,7 @@ function ConversationTab({ } const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) const markdownGitHubRepo = useMemo( () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), [bodySlug, projectOrigin] @@ -3171,6 +3247,7 @@ function ConversationTab({ repoId: item.repoId, sourceContext, prNumber: item.number, + prRepo, commentId: comment.id, body: replyBody, threadId: comment.threadId, @@ -3183,7 +3260,8 @@ function ConversationTab({ sourceContext, number: item.number, body: `@${comment.author} ${replyBody}`, - type: item.type + type: item.type, + prRepo }) if (!result.ok) { @@ -3204,6 +3282,7 @@ function ConversationTab({ item.repoId, item.type, onCommentAdded, + prRepo, repoPath, sourceContext ] @@ -3234,6 +3313,7 @@ function ConversationTab({ loading={loading} repoPath={repoPath} sourceContext={sourceContext} + projectOrigin={projectOrigin} onReviewersRequested={onReviewersRequested} />