From 971b1675480afe59f043dd11542842521d1dcc7a Mon Sep 17 00:00:00 2001 From: Wonjae Lee Date: Tue, 21 Jul 2026 10:55:45 +0900 Subject: [PATCH] fix(github): load PR diffs for Enterprise remotes (#8932) * fix(github): load PR diffs for Enterprise remotes * fix(github): encode PR content paths by segment * Fix PR review actions failing on GitHub Enterprise remotes - Threads GitHub host identity (not just owner/repo) through the client, work-item-details, issues, and RPC layers so gh commands target the correct Enterprise server instead of silently falling back to github.com - Adds a shared github-api-repository helper to resolve/host-qualify repo identity consistently across REST, GraphQL, and CLI shorthand calls - Scopes the gh rate-limit breaker and singleton rate-limit snapshot by host/runtime so a github.com block or probe can't affect GHES or WSL - Coalesces concurrent host-auth probes and paginates PR file fetching beyond 100 results - Propagates `host` through renderer PR caches, checks-panel keys, and preload IPC types so Enterprise and github.com data never collide * Route gh host qualification through runner options instead of argv sniff Move GHES/GH_HOST resolution from parsing --hostname/--repo out of gh argv to an explicit options.host passed through ghExecFileAsync, since SSH-backed repos spawn gh with no cwd and argv sniffing couldn't reliably detect the target host. The runner now injects --hostname and qualifies --repo/-R at spawn time from options.host, and rate-limit scoping/guards use the same explicit host instead of inferring it. Also adds a shared githubRepoIdentityKey helper to keep cache/store keys consistent with the new host-aware repository identity. * Fix gh CLI GHES host pinning and rate-limit scope leaks - Pin `--host` on every gh call site so a process-level GH_HOST can't silently redirect requests, and qualify `-R`/`-R=` repo shorthand alongside the existing `--repo=` handling. - Check the target scope for an active rate-limit block before each WSL/native or host fallback retry, not just on the initial attempt, so a blocked scope can't be hit again through a fallback path. - Compute idempotency once per call instead of re-deriving it after fallback reassigns args. * Fix GitHub Enterprise host identity loss across PR/work-item paths - Thread `host` through mobile PR RPC params, IPC work-item lookups, and RPC schemas so GHES identity survives the renderer/mobile/main boundary instead of silently falling back to a same-named github.com repo. - Qualify `--repo`/`-R` args for github.com too (not just GHES), since gh resolves bare shorthand against a process-level GH_HOST that can redirect pinned github.com commands. - Cache `getOriginGitHubApiRepository` to avoid a per-call uncached `git remote get-url` round trip on connection-backed repos. - Add a local-fork fallback in `getWorkItemDetails` so PRs living on a base repo (not visible via the origin slug) still resolve via cwd. - Centralize the github.com-vs-GHES host predicate in `isDefaultGitHubHost` so cache keys, quota scoping, and identity checks can't drift out of sync. * Make repository identity host-aware across all GitHub surfaces Generalize the auth-gated enterprise resolver to any remote and build a cached hosted-identity family (origin/issue/candidates/source) on top of it, then migrate every github.com-only consumer: Tasks listing/counting, branch-to-PR discovery, push targets, fork upstream, issue operations, Projects, web links, avatars, and PR-link facts. Scope the rate-limit breaker probe per runtime:host and classify WSL UNC cwds correctly. Co-authored-by: Orca * Fix expected slug to include host field in GitHub PR link test Updates the smart-source paste-intent test fixture to match the repository slug shape that now carries a `host` field, keeping GHES host identity intact through the paste-intent parsing path. * Surface per-host gh auth state for GitHub Enterprise diagnoseGhAuth accepts the host a surface needs credentials for, scopes the account/scope diagnosis to that host, and reports whether gh has any login there; GhAuthErrorHelp renders host-qualified login/refresh commands so an unauthenticated GHES host stops masquerading as a github.com scope problem. Also fixes the mobile paste-intent expectation for host-carrying parsed links. Co-authored-by: Orca * Bound GHES identity caches and preserve non-default ports in host identity Cap the origin-repo and host-auth caches like ownerRepoCache; keep ports from remote/link URLs so GHES on a non-default port is a distinct identity; make positional github.com slugs explicit against GH_HOST; compare work-item sources by host-aware identity key; bail cwd-less branch lookups when no repository candidate resolved; thread host through the renderer work-item slug lookup. Co-authored-by: Orca * Thread GitHub host through issue detail requests Incorporates ghes-issue-host-support (ed6bb96ef): one hosted issue repository identity is resolved before the details fan-out so comments, timeline, participants, and mention lookups cannot drift across hosts, with SSH guards so unresolved issue/PR repositories never fall through to gh's default host. Co-authored-by: Orca * Scope remaining GitHub rate-limit accounting * Resolve typed PR lookups across hosted repository candidates getWorkItem's PR path probes upstream-then-origin hosted candidates instead of origin alone, so fork checkouts resolve the base repo's PR with the right host; issue detail resolution reuses the up-front hosted identity and keeps the SSH unresolved-host guards. Co-authored-by: Orca * Refactor GitHub repository execution setup * Carry host on smart-submit link intents Co-authored-by: Orca * Carry the project host on GitHub item dialog origins Co-authored-by: Orca * Keep GHES web ports but drop SSH transport ports in host identity Supersedes PR #9118 on this branch: http(s) remote ports identify the Enterprise web/API endpoint and are preserved, while ssh/git transport ports (including ssh.github.com:443) never leak into gh's host identity. Replaces the ssh.github.com:443 special case with the structural protocol split and ports the PR's parsing test suite. Co-authored-by: Orca * Support GitHub Enterprise diffs and mutations with host-scoped caches Parse GitHub host identity from work-item URLs and carry it through PR/issue mutations, labels, and assignments. Bound rate-limit and scope-probe caches (1024 and 512 entries) to prevent unbounded growth when interacting with multiple GHES instances. Normalize repository identity keys to include host so github.com and GHES slugs don't collide in cache and equality checks. * Support GitHub Enterprise diffs and mutations with host-scoped caches - Carry host identity through PR mutations and reads so fork PRs on different GHES instances don't collide in cache or state tracking. - Validate host authentication before routing requests to unconfigured Enterprise servers; ambient credentials must never reach untrusted hosts. - Scope rate-limit guards and spend tracking per host so GHES quota stays independent from github.com quota. - Respect explicit --hostname arguments in gh CLI calls ahead of GH_HOST or ambient defaults, so breaker state follows the actual request target. - Detect implicit WSL runtimes from UNC paths for consistent host auth and execution-options scoping across mobile and desktop clients. * Support GitHub Enterprise work-item diffs with host-scoped execution Enterprise PRs must use their selected host consistently across diff, comments, and file-content loads. Validate repository slugs before authenticated execution to prevent path-injection via renderer overrides. Scope project browsing cache and rate-limit tracking by host to prevent cross-host pollution. Use parsed URLs as authoritative over ambient hosts for project resolution. * Support GitHub Enterprise work-item diffs with host-scoped execution Preserve host identity on PR/issue work items throughout the mutation and diff pipeline so Enterprise instances (including ported endpoints like github.acme.test:8443) can execute mutations without ambiguity. Rate-limit gh commands by the pre-qualified --repo host, cache auth state per ported host, and surface Enterprise hosts in project metadata and error messages. * fix(review): drop dead rateLimitGuard/noteRateLimitSpend re-export Both callers (project-view.ts, mutations.ts) moved to the host-scoped repositoryRateLimitGuard/noteRepositoryRateLimitSpend; the bucket-only re-export in internals.ts had zero importers left. Co-authored-by: Orca * fix(ci): split Enterprise host work-item tests under max-lines Move GHES/SSH host-routing cases out of work-item-details.test.ts so the suite stays within the 800-line test max-lines budget. * test(github): align mocks with host-scoped repository resolution - Route origin repository resolution through getOwnerRepoForRemote, not getOwnerRepo, to match production path - Pin github.com host on origin results so host-less fixtures pass host gate in resolveGitHubApiRepository - Add generation-based invalidation to prevent stale slug-cache writes from in-flight resolutions - Fix ref-sync race in ProjectPicker: use useLayoutEffect so committed tree owns browse cache key - Defer handledCrossRepoUrlRef assignment in SmartWorkspaceNameField until resolution succeeds - Update Enterprise host routing: found work items must not silently fall back to default host when unresolved - Normalize GHES avatar URLs: accept explicit port 443 as canonical form, not a fallback trigger --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- mobile/app/h/[hostId]/tasks.tsx | 240 +++---- .../components/pr-sidebar/PRCommentCard.tsx | 2 +- .../src/session/github-pr-mutations.test.ts | 51 +- mobile/src/session/github-pr-mutations.ts | 96 ++- mobile/src/session/github-pr-rpc.test.ts | 40 +- mobile/src/session/github-pr-rpc.ts | 34 +- .../session/github-pr-value-readers.test.ts | 8 + mobile/src/session/github-pr-value-readers.ts | 5 +- .../session/pr-action-mutation-contract.ts | 38 ++ mobile/src/session/pr-actions-engine.test.ts | 39 ++ mobile/src/session/pr-actions-engine.ts | 49 +- mobile/src/session/pr-comment-actions.test.ts | 10 +- mobile/src/session/pr-comment-actions.ts | 29 +- .../src/session/use-mobile-pr-actions.test.ts | 102 ++- .../session/use-mobile-pr-comment-actions.ts | 12 +- ...github-project-host-routing-source.test.ts | 42 ++ .../tasks/github-project-reference.test.ts | 39 ++ mobile/src/tasks/github-project-reference.ts | 84 +++ .../tasks/github-project-repo-match.test.ts | 60 +- mobile/src/tasks/github-project-repo-match.ts | 40 +- .../tasks/smart-source-paste-intent.test.ts | 52 +- mobile/src/tasks/smart-source-paste-intent.ts | 14 +- .../src/tasks/use-smart-workspace-source.ts | 12 +- src/main/git/gh-rate-limit-breaker.test.ts | 89 ++- src/main/git/gh-rate-limit-breaker.ts | 101 ++- src/main/git/runner-gh-host-args.test.ts | 142 +++++ .../git/runner-gh-rate-limit-breaker.test.ts | 121 +++- src/main/git/runner-wsl-gh-fallback.test.ts | 136 ++++ src/main/git/runner.ts | 166 ++++- src/main/github/auth-diagnose.test.ts | 67 +- src/main/github/auth-diagnose.ts | 19 +- src/main/github/client-create-pr.test.ts | 40 +- src/main/github/client-file-viewed.test.ts | 92 ++- src/main/github/client-issue-source.test.ts | 130 +++- .../github/client-pr-check-details.test.ts | 85 ++- src/main/github/client-pr-checks.test.ts | 72 ++- .../github/client-pr-local-runtime.test.ts | 347 +++++++++- .../client-work-items-query-paging.test.ts | 302 +++++++++ src/main/github/client-work-items.test.ts | 188 ++---- src/main/github/client.test.ts | 398 ++++++++++-- src/main/github/client.ts | 593 ++++++++++++------ .../github/default-branch-stale-pr.test.ts | 7 +- src/main/github/github-api-repository.test.ts | 205 ++++++ src/main/github/github-api-repository.ts | 360 +++++++++++ .../github-enterprise-repository.test.ts | 162 ++++- .../github/github-enterprise-repository.ts | 216 +++++-- .../github-remote-identity-parsing.test.ts | 109 ++++ .../github/github-remote-identity-parsing.ts | 11 +- ...epository-identity.fork-owner-repo.test.ts | 3 +- src/main/github/github-repository-identity.ts | 3 +- src/main/github/issues.test.ts | 105 +++- src/main/github/issues.ts | 108 +++- .../github/merged-pr-commit-membership.ts | 15 +- .../github/pr-refresh-coordinator.test.ts | 109 +++- src/main/github/pr-refresh-coordinator.ts | 42 +- .../github/project-view-host-auth.test.ts | 125 ++++ src/main/github/project-view.test.ts | 61 +- src/main/github/project-view.ts | 225 ++++--- src/main/github/project-view/internals.ts | 258 +++----- .../github/project-view/mutations.test.ts | 147 ++++- src/main/github/project-view/mutations.ts | 140 +++-- .../project-error-classification.ts | 177 ++++++ src/main/github/rate-limit.test.ts | 236 ++++++- src/main/github/rate-limit.ts | 131 +++- .../work-item-details-concurrency.test.ts | 159 +++++ .../work-item-details-enterprise-host.test.ts | 313 +++++++++ .../work-item-details-file-viewed.test.ts | 54 +- .../github/work-item-details-pr-files.test.ts | 359 +++++++++++ src/main/github/work-item-details.test.ts | 204 ++++-- src/main/github/work-item-details.ts | 422 +++++++------ src/main/ipc/github.test.ts | 36 +- src/main/ipc/github.ts | 58 +- src/main/ipc/repos-remote.test.ts | 55 ++ src/main/ipc/repos.ts | 3 +- src/main/repo-icon-autodetect.test.ts | 3 +- src/main/runtime/orca-runtime.test.ts | 61 +- src/main/runtime/orca-runtime.ts | 44 +- .../runtime/rpc/methods/client-ui-schemas.ts | 3 +- .../runtime/rpc/methods/client-ui.test.ts | 16 +- src/main/runtime/rpc/methods/github.test.ts | 49 +- src/main/runtime/rpc/methods/github.ts | 89 ++- src/main/runtime/runtime-rpc.test.ts | 16 +- .../source-control/forge-provider.test.ts | 13 +- src/main/source-control/forge-provider.ts | 25 +- ...hosted-review-creation-eligibility.test.ts | 17 +- .../hosted-review-creation.test.ts | 21 +- .../source-control/hosted-review-creation.ts | 6 +- src/preload/api-types.ts | 19 +- src/preload/index.ts | 36 +- .../src/components/GitHubItemDialog.tsx | 197 ++++-- .../src/components/PullRequestPage.tsx | 192 ++++-- src/renderer/src/components/TaskPage.tsx | 67 +- ...b-enterprise-slug-routing-boundary.test.ts | 63 ++ ...github-item-dialog-source-boundary.test.ts | 19 +- .../github-project/GhAuthErrorHelp.test.ts | 60 ++ .../github-project/GhAuthErrorHelp.tsx | 61 +- .../components/github-project/ProjectCell.tsx | 38 +- .../github-project/ProjectPicker.tsx | 196 ++++-- .../components/github-project/ProjectRow.tsx | 3 + .../github-project/ProjectViewList.tsx | 1 + .../github-project/ProjectViewWrapper.tsx | 132 ++-- .../github-project-picker-filter.test.ts | 18 +- .../github-project-picker-filter.ts | 5 +- .../github-project/project-dialog-state.ts | 13 +- .../project-picker-input.test.ts | 38 ++ .../project-row-filtering.test.ts | 17 +- .../github-project/project-row-filtering.ts | 18 +- ...ew-wrapper-source-context-boundary.test.ts | 35 ++ .../slug-dialog/AssigneesEditor.tsx | 5 +- .../github-project/slug-dialog/Comments.tsx | 24 +- .../slug-dialog/LabelsEditor.tsx | 9 +- .../slug-dialog/SlugDialogBody.tsx | 12 +- .../github/IssueSourceIndicator.tsx | 28 +- .../components/github/PRFilterDropdowns.tsx | 6 +- ...rkspaceNameField-repo-slug-routing.test.ts | 75 +++ .../new-workspace/SmartWorkspaceNameField.tsx | 123 ++-- .../pull-request-page-host-boundary.test.ts | 14 +- .../components/right-sidebar/ChecksPanel.tsx | 3 + .../checks-panel-async-result-key.test.ts | 17 + .../checks-panel-async-result-key.ts | 6 +- .../hosted-review-github-actions.ts | 3 + .../use-hosted-review-actions.ts | 2 + .../settings/repository-icon-github.test.ts | 15 + .../settings/repository-icon-github.ts | 3 +- .../parked-terminal-byte-watcher.test.ts | 2 +- .../terminal-pane/pty-connection.test.ts | 2 +- ...poserState-host-context-boundaries.test.ts | 12 + src/renderer/src/hooks/useComposerState.ts | 16 +- .../src/hooks/useGitHubSlugMetadata.test.tsx | 27 +- .../src/hooks/useGitHubSlugMetadata.ts | 27 +- src/renderer/src/lib/github-links.test.ts | 28 +- .../src/lib/github-work-item-source-lookup.ts | 3 + .../src/lib/project-host-clone-url.test.ts | 33 + .../src/lib/project-host-clone-url.ts | 23 +- src/renderer/src/lib/repo-slug-cache.test.ts | 57 ++ src/renderer/src/lib/repo-slug-cache.ts | 69 +- src/renderer/src/lib/repo-slug-index.ts | 153 +++-- .../src/lib/smart-github-submit.test.ts | 20 + src/renderer/src/lib/smart-github-submit.ts | 11 +- .../src/store/slices/github-checks.test.ts | 30 + .../src/store/slices/github-checks.ts | 6 +- .../store/slices/github-project-row-owner.ts | 8 +- src/renderer/src/store/slices/github.test.ts | 89 ++- src/renderer/src/store/slices/github.ts | 41 +- src/renderer/src/web/web-preload-api.test.ts | 3 +- src/renderer/src/web/web-preload-api.ts | 5 +- src/shared/github-auth-types.ts | 7 + src/shared/github-links.ts | 12 +- src/shared/github-project-identity.test.ts | 22 + src/shared/github-project-identity.ts | 23 + src/shared/github-project-types.ts | 34 +- .../github-repository-identity-key.test.ts | 14 + src/shared/github-repository-identity-key.ts | 19 + .../project-host-setup-projection.test.ts | 121 ++++ src/shared/project-host-setup-projection.ts | 123 +++- src/shared/repo-icon.test.ts | 38 +- src/shared/repo-icon.ts | 32 +- src/shared/task-source-context.test.ts | 13 + src/shared/task-source-context.ts | 3 +- .../terminal-github-pr-link-detector.test.ts | 21 +- src/shared/types.ts | 6 +- 161 files changed, 9934 insertions(+), 1959 deletions(-) create mode 100644 mobile/src/session/pr-action-mutation-contract.ts create mode 100644 mobile/src/tasks/github-project-host-routing-source.test.ts create mode 100644 mobile/src/tasks/github-project-reference.test.ts create mode 100644 mobile/src/tasks/github-project-reference.ts create mode 100644 src/main/git/runner-gh-host-args.test.ts create mode 100644 src/main/github/client-work-items-query-paging.test.ts create mode 100644 src/main/github/github-api-repository.test.ts create mode 100644 src/main/github/github-api-repository.ts create mode 100644 src/main/github/github-remote-identity-parsing.test.ts create mode 100644 src/main/github/project-view-host-auth.test.ts create mode 100644 src/main/github/project-view/project-error-classification.ts create mode 100644 src/main/github/work-item-details-concurrency.test.ts create mode 100644 src/main/github/work-item-details-enterprise-host.test.ts create mode 100644 src/main/github/work-item-details-pr-files.test.ts create mode 100644 src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts create mode 100644 src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts create mode 100644 src/renderer/src/components/github-project/project-picker-input.test.ts create mode 100644 src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts create mode 100644 src/renderer/src/lib/repo-slug-cache.test.ts create mode 100644 src/shared/github-project-identity.test.ts create mode 100644 src/shared/github-project-identity.ts create mode 100644 src/shared/github-repository-identity-key.test.ts create mode 100644 src/shared/github-repository-identity-key.ts 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} />