diff --git a/documentation/releasing.md b/documentation/releasing.md index 9be934ecb4..bf39237b0a 100644 --- a/documentation/releasing.md +++ b/documentation/releasing.md @@ -1,5 +1,19 @@ # How to release +## Authentication API dependency + +Enterprise API destinations use the selected session's `authorizationServer`, +with `/login/oauth` removed, rather than `github-enterprise.uri` (setup only). +This requires the **proposed** `authIssuers` API from +[microsoft/vscode#337846](https://github.com/microsoft/vscode/pull/337846), +merged at `2f84f5b38264788d80a9399413cc47cb2a17db51` on the 1.140 development line. +The engine floor is 1.140.0; Insiders must contain that commit. Production +allowlisting is a separate [vscode-distro](https://github.com/microsoft/vscode-distro) +change. Missing enterprise metadata is explicitly unavailable, without a +configuration fallback. Public GitHub/PAT authentication does not require it. + +## Release steps + 1. Edit version in [package.json](https://github.com/Microsoft/vscode-pull-request-github/blob/main/package.json) - Update version of the extension - this is usually the minor version. **Until the marketplace supports semantic versioning, the minor version should always be an event number. Odd numbers are reserved for the pre-release version of the extension.** diff --git a/package.json b/package.json index 1f8f75f8eb..b89ae1f47a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "activeComment", "agentSessionsWorkspace", "agentsWindowActivation", + "authIssuers", "chatContextProvider", "chatParticipantAdditions", "chatParticipantPrivate", @@ -48,7 +49,7 @@ "publisher": "GitHub", "engines": { "node": ">=20", - "vscode": "^1.137.0" + "vscode": "^1.140.0" }, "categories": [ "Other", diff --git a/src/@types/vscode.proposed.authIssuers.d.ts b/src/@types/vscode.proposed.authIssuers.d.ts new file mode 100644 index 0000000000..c184e42e4d --- /dev/null +++ b/src/@types/vscode.proposed.authIssuers.d.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export interface AuthenticationSession { + /** + * The authorization server that issued this session, when provided by the authentication provider. + * This identifies the OAuth server, not a REST API endpoint or resource audience. + */ + readonly authorizationServer?: Uri; + } + + export interface AuthenticationProviderOptions { + /** + * When specified, this provider will be associated with these authorization servers. They can still contain globs + * just like their extension contribution counterparts. + */ + readonly supportedAuthorizationServers?: Uri[]; + } + + export interface AuthenticationProviderSessionOptions { + /** + * When specified, the authentication provider will use the provided authorization server URL to + * authenticate the user. This is only used when a provider has `supportedAuthorizationServers` set + */ + authorizationServer?: Uri; + + /** + * When specified, the authentication provider will use the provided client ID for the OAuth flow + * instead of its default client ID. + */ + clientId?: string; + + /** + * When specified, the authentication provider will request a token bound to this resource URI + * (RFC 8707 resource indicator). The provider should forward this to the authorization server + * so the issued access token is audience-restricted to the given resource. + */ + resource?: string; + } + + export interface AuthenticationGetSessionOptions { + /** + * When specified, the authentication provider will use the provided authorization server URL to + * authenticate the user. This is only used when a provider has `supportedAuthorizationServers` set + */ + authorizationServer?: Uri; + + /** + * When specified, the authentication provider will use the provided client ID for the OAuth flow + * instead of its default client ID. + */ + clientId?: string; + + /** + * When specified, the authentication provider will request a token bound to this resource URI + * (RFC 8707 resource indicator). The provider should forward this to the authorization server + * so the issued access token is audience-restricted to the given resource. + */ + resource?: string; + } +} diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 78f192c177..765d27b596 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -6,11 +6,11 @@ import fetch from 'cross-fetch'; import * as vscode from 'vscode'; import { HostHelper } from './configuration'; -import { GitHubServerType } from '../common/authentication'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; import Logger from '../common/logger'; import { ITelemetry } from '../common/telemetry'; import { agent } from '../env/node/net'; -import { getEnterpriseUri } from '../github/utils'; +import type { CredentialStore } from '../github/credentials'; export class GitHubManager { private static readonly _githubDotComServers = new Set().add('github.com').add('ssh.github.com'); @@ -19,7 +19,10 @@ export class GitHubManager { private static readonly _reportedEnterpriseVersions = new Set(); private _knownServers: Map = new Map([...Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom]), ...Array.from(GitHubManager._gheServers.keys()).map(key => [key, GitHubServerType.Enterprise])] as [string, GitHubServerType][]); - constructor(private readonly _telemetry?: ITelemetry) { } + constructor( + private readonly credentialStore: CredentialStore, + private readonly _telemetry?: ITelemetry, + ) { } public static isGithubDotCom(host: string): boolean { return this._githubDotComServers.has(host); @@ -60,8 +63,9 @@ export class GitHubManager { const matchingKnownServer = Array.from(this._knownServers.keys()).find(server => authority.endsWith(server)); - const knownEnterprise = getEnterpriseUri(); - if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!matchingKnownServer || (this._knownServers.get(matchingKnownServer) === GitHubServerType.None))) { + const enterprise = this.credentialStore.getHub(AuthProvider.githubEnterprise); + if (enterprise && authority === new URL(enterprise.serverUri.toString()).hostname.toLowerCase() + && (!matchingKnownServer || (this._knownServers.get(matchingKnownServer) === GitHubServerType.None))) { return GitHubServerType.Enterprise; } diff --git a/src/common/authentication.ts b/src/common/authentication.ts index 92a81972d9..aedbf0e0d8 100644 --- a/src/common/authentication.ts +++ b/src/common/authentication.ts @@ -17,11 +17,36 @@ export enum AuthProvider { } export class AuthenticationError extends Error { - constructor() { - super(vscode.l10n.t('Not authenticated')); + constructor(message: string = vscode.l10n.t('Not authenticated')) { + super(message); } } +export function getSessionGitHubUri(authProviderId: AuthProvider, session: Pick | undefined): vscode.Uri { + if (authProviderId === AuthProvider.github) { + return vscode.Uri.parse('https://github.com'); + } + + const issuer = session?.authorizationServer; + const oauthSuffix = '/login/oauth'; + const path = issuer?.path.replace(/\/$/, ''); + const unavailable = () => new AuthenticationError(vscode.l10n.t('GitHub Enterprise is unavailable because the authentication session does not include a supported authorization server. Use a VS Code build with authIssuers session support and sign in again.')); + if (!issuer || (issuer.scheme !== 'https' && issuer.scheme !== 'http') || !issuer.authority + || /[@\s]/.test(issuer.authority) || issuer.query || issuer.fragment || !path?.endsWith(oauthSuffix)) { + throw unavailable(); + } + try { + new URL(issuer.toString()); + } catch (error) { + if (error instanceof TypeError) { + throw unavailable(); + } + throw error; + } + + return issuer.with({ path: path.slice(0, -oauthSuffix.length) }); +} + export function isSamlError(e: { message?: string }): boolean { return !!e.message?.includes('Resource protected by organization SAML enforcement.'); } diff --git a/src/common/remote.ts b/src/common/remote.ts index e32ec56e90..3731bd22b5 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; import { AuthProvider, GitHubServerType } from './authentication'; import Logger from './logger'; -import { Protocol } from './protocol'; +import { Protocol, ProtocolType } from './protocol'; import { Repository } from '../api/api'; -import { getEnterpriseUri, isEnterprise } from '../github/utils'; +import { isEnterprise } from '../github/utils'; export class Remote { public get host(): string { @@ -21,12 +22,18 @@ export class Remote { } public get normalizedHost(): string { + if (this.gitProtocol.type === ProtocolType.HTTP) { + const uri = this.gitProtocol.url; + const path = uri.path.replace(/\/$/, ''); + const deploymentPath = path.slice(0, path.lastIndexOf('/', path.lastIndexOf('/') - 1)); + return uri.with({ authority: uri.authority.replace(/^.*@/, ''), path: deploymentPath, query: '', fragment: '' }).toString().replace(/\/$/, ''); + } const normalizedUri = this.gitProtocol.normalizeUri(); return `${normalizedUri!.scheme}://${normalizedUri!.authority}`; } public get authProviderId(): AuthProvider { - return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github; + return ['github.com', 'ssh.github.com'].includes(this.host.toLowerCase()) ? AuthProvider.github : AuthProvider.githubEnterprise; } public get isEnterprise(): boolean { @@ -39,6 +46,19 @@ export class Remote { public readonly gitProtocol: Protocol, ) { } + public matchesServerUri(serverUri: vscode.Uri): boolean { + const server = new URL(serverUri.toString()); + if (server.hostname.toLowerCase() === 'github.com') { + return ['github.com', 'ssh.github.com'].includes(this.host.toLowerCase()); + } + if (this.gitProtocol.type !== ProtocolType.HTTP) { + return this.host.toLowerCase() === server.hostname.toLowerCase(); + } + const remote = new URL(this.normalizedHost); + return remote.host.toLowerCase() === server.host.toLowerCase() + && remote.pathname.replace(/\/$/, '') === server.pathname.replace(/\/$/, ''); + } + equals(remote: Remote): boolean { if (this.remoteName !== remote.remoteName) { return false; diff --git a/src/extension.ts b/src/extension.ts index 6c99976cee..965f8d3513 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -278,7 +278,8 @@ async function init( if (e.provider.id !== AuthProvider.github && e.provider.id !== AuthProvider.githubEnterprise) { return; } - if (e.accountChanged) { + const clearAuthState = e.accountChanged || e.serverChanged; + if (clearAuthState) { IssueOverviewPanel.clearAll(); PullRequestOverviewPanel.clearAll(); activePrViewCoordinator.clearForAuthChange(); @@ -291,7 +292,7 @@ async function init( } await reposManager.refreshRepositories(); await Promise.all(reviewsManager.reviewManagers.map(reviewManager => reviewManager.updateState(true))); - reviewsManager.refreshPullRequestsTree(!e.accountChanged); + reviewsManager.refreshPullRequestsTree(!clearAuthState); await issueStateManager.refreshAfterAuthChange(); notificationsManager.refresh(); })); diff --git a/src/gitExtensionIntegration.ts b/src/gitExtensionIntegration.ts index 88ee9cc940..a003a6f25d 100644 --- a/src/gitExtensionIntegration.ts +++ b/src/gitExtensionIntegration.ts @@ -37,7 +37,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { readonly icon = 'github'; readonly supportsQuery = true; - private userReposCache: RemoteSource[] = []; + private userReposCache = new WeakMap(); constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) { if (isEnterprise(authProviderId)) { @@ -65,10 +65,10 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { private async getUserRemoteSources(hub: GitHub, query?: string): Promise { if (!query) { const res = await hub.octokit.call(hub.octokit.api.repos.listForAuthenticatedUser, { sort: 'pushed', per_page: 100 }); - this.userReposCache = res.data.map(asRemoteSource); + this.userReposCache.set(hub, res.data.map(asRemoteSource)); } - return this.userReposCache; + return this.userReposCache.get(hub) ?? []; } private async getQueryRemoteSources(hub: GitHub, query?: string): Promise { diff --git a/src/github/copilotApi.ts b/src/github/copilotApi.ts index 7b172e3cae..c91cf986d8 100644 --- a/src/github/copilotApi.ts +++ b/src/github/copilotApi.ts @@ -7,7 +7,6 @@ import fetch from 'cross-fetch'; import * as vscode from 'vscode'; import { CredentialStore } from './credentials'; import { LoggingOctokit } from './loggingOctokit'; -import { hasEnterpriseUri } from './utils'; import { AuthProvider } from '../common/authentication'; import Logger from '../common/logger'; import { ITelemetry } from '../common/telemetry'; @@ -117,7 +116,7 @@ export interface SessionInfo { export async function getCopilotApi(credentialStore: CredentialStore, telemetry: ITelemetry, authProvider?: AuthProvider): Promise { if (!authProvider) { - if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { authProvider = AuthProvider.githubEnterprise; } else if (credentialStore.isAuthenticated(AuthProvider.github)) { authProvider = AuthProvider.github; diff --git a/src/github/credentials.ts b/src/github/credentials.ts index d5974e07e6..2bbec3dc03 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -11,14 +11,12 @@ import fetch from 'cross-fetch'; import * as vscode from 'vscode'; import { IAccount } from './interface'; import { LoggingApolloClient, LoggingOctokit, RateLogger } from './loggingOctokit'; -import { convertRESTUserToAccount, getEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; -import { AuthProvider } from '../common/authentication'; +import { convertRESTUserToAccount, isEnterprise } from './utils'; +import { AuthenticationError, AuthProvider, getSessionGitHubUri } from '../common/authentication'; import { commands } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; import * as PersistentState from '../common/persistentState'; -import { GITHUB_ENTERPRISE, URI } from '../common/settingKeys'; -import { initBasedOnSettingChange } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; import { agent } from '../env/node/net'; @@ -53,6 +51,24 @@ export function hasAccountChanged(currentAccountId: string | undefined, newSessi return currentAccountId !== newSession?.account.id; } +function sessionAccountOptions(session: vscode.AuthenticationSession): vscode.AuthenticationGetSessionOptions { + return { + account: session.account, + ...(session.authorizationServer ? { authorizationServer: session.authorizationServer } : {}), + }; +} + +function isSameSessionAccount(first: vscode.AuthenticationSession, second: vscode.AuthenticationSession): boolean { + return first.account.id === second.account.id + && first.authorizationServer?.toString() === second.authorizationServer?.toString(); +} + +function isSameSession(first: vscode.AuthenticationSession | undefined, second: vscode.AuthenticationSession | undefined): boolean { + return first === second || (!!first && !!second && isSameSessionAccount(first, second) + && first.id === second.id && first.accessToken === second.accessToken + && first.scopes.length === second.scopes.length && first.scopes.every(scope => second.scopes.includes(scope))); +} + export async function findExistingSession( authProviderId: AuthProvider, getSession: AuthenticationSessionGetter = (providerId, scopes, options) => vscode.authentication.getSession(providerId, scopes, options), @@ -63,8 +79,8 @@ export async function findExistingSession( if (preferredSession) { const scopesInPreferenceOrder = [SCOPES_WITH_ADDITIONAL, SCOPES_OLD, SCOPES_OLDEST]; for (const scopes of scopesInPreferenceOrder) { - const session = await getSession(authProviderId, scopes, { silent: true, account: preferredSession.account }); - if (session) { + const session = await getSession(authProviderId, scopes, { silent: true, ...sessionAccountOptions(preferredSession) }); + if (session && isSameSessionAccount(preferredSession, session)) { return { session, scopes }; } } @@ -84,8 +100,8 @@ export async function findExistingSession( } for (const broaderScopes of preference.broaderScopes) { - const broaderSession = await getSession(authProviderId, broaderScopes, { silent: true, account: session.account }); - if (broaderSession) { + const broaderSession = await getSession(authProviderId, broaderScopes, { silent: true, ...sessionAccountOptions(session) }); + if (broaderSession && isSameSessionAccount(session, broaderSession)) { return { session: broaderSession, scopes: broaderScopes }; } } @@ -94,6 +110,8 @@ export async function findExistingSession( } export interface GitHub { + readonly serverUri: vscode.Uri; + readonly session?: vscode.AuthenticationSession; octokit: LoggingOctokit; graphql: LoggingApolloClient; currentUser?: IAccount; @@ -102,20 +120,18 @@ export interface GitHub { interface AuthResult { canceled: boolean; + unavailable?: boolean; } export interface CredentialStoreSessionsChangeEvent extends vscode.AuthenticationSessionsChangeEvent { accountChanged: boolean; + serverChanged: boolean; } export class CredentialStore extends Disposable { private static readonly ID = 'Authentication'; private _githubAPI: GitHub | undefined; - private _sessionId: string | undefined; - private _accountId: string | undefined; private _githubEnterpriseAPI: GitHub | undefined; - private _enterpriseSessionId: string | undefined; - private _enterpriseAccountId: string | undefined; private _isInitialized: boolean = false; private _onDidInitialize: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidInitialize: vscode.Event = this._onDidInitialize.event; @@ -147,48 +163,31 @@ export class CredentialStore extends Disposable { } private async handlOnDidChangeSessions(e: vscode.AuthenticationSessionsChangeEvent) { - const currentProvider = (e.provider.id === AuthProvider.github && this._githubAPI) ? AuthProvider.github : ((e.provider.id === AuthProvider.githubEnterprise && this._githubEnterpriseAPI) ? AuthProvider.githubEnterprise : undefined); - if ((this._githubAPI || this._githubEnterpriseAPI) && !currentProvider) { + const currentProvider = e.provider.id; + if (currentProvider !== AuthProvider.github && currentProvider !== AuthProvider.githubEnterprise) { return; } - let sessionChanged = false; - let accountChanged = false; - if (currentProvider) { - const newSession = await this.getSession(currentProvider, { silent: true }, currentProvider === AuthProvider.github ? this._scopes : this._scopesEnterprise, false); - const currentSessionId = currentProvider === AuthProvider.github ? this._sessionId : this._enterpriseSessionId; - if (newSession.session?.id === currentSessionId) { - return; - } - sessionChanged = true; - if (currentProvider === AuthProvider.github) { - accountChanged = hasAccountChanged(this._accountId, newSession.session); - this._githubAPI = undefined; - this._sessionId = undefined; - this._accountId = undefined; - } else { - accountChanged = hasAccountChanged(this._enterpriseAccountId, newSession.session); - this._githubEnterpriseAPI = undefined; - this._enterpriseSessionId = undefined; - this._enterpriseAccountId = undefined; - } - } - const promises: Promise[] = []; - if (!this.isAuthenticated(AuthProvider.github)) { - promises.push(this.initialize(AuthProvider.github)); + const previousHub = this.getHub(currentProvider); + const newSession = await this.getSession(currentProvider, { silent: true }, currentProvider === AuthProvider.github ? this._scopes : this._scopesEnterprise, false); + if (this.getHub(currentProvider) !== previousHub || isSameSession(previousHub?.session, newSession.session)) { + return; } - - if (!this.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - promises.push(this.initialize(AuthProvider.githubEnterprise)); + if (currentProvider === AuthProvider.github) { + this._githubAPI = undefined; + } else { + this._githubEnterpriseAPI = undefined; } - - await Promise.all(promises); + await this.initialize(currentProvider, { silent: true }); + const github = this.getHub(currentProvider); if (this.isAnyAuthenticated()) { this._onDidGetSession.fire(); - if (sessionChanged && !this._isSamling) { - this._onDidChangeSessions.fire({ ...e, accountChanged }); - } - } else if (!this._isSamling) { - this._onDidChangeSessions.fire({ ...e, accountChanged }); + } + if (!this._isSamling) { + this._onDidChangeSessions.fire({ + ...e, + accountChanged: hasAccountChanged(previousHub?.session?.account.id, github?.session), + serverChanged: isEnterprise(currentProvider) && previousHub?.serverUri.toString() !== github?.serverUri.toString(), + }); } } @@ -220,10 +219,8 @@ export class CredentialStore extends Disposable { } Logger.debug('Attempting authentication using GITHUB_OAUTH_TOKEN environment variable.', CredentialStore.ID); try { - const github = await this.createHub(token, authProviderId); + const github = this.createHub(token, authProviderId); this._githubAPI = github; - this._sessionId = 'environment-token'; - this._accountId = undefined; if (!this._isInitialized) { this._isInitialized = true; this._onDidInitialize.fire(); @@ -238,18 +235,14 @@ export class CredentialStore extends Disposable { private async initialize(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions = {}, scopes: string[] = (!isEnterprise(authProviderId) ? this._scopes : this._scopesEnterprise), requireScopes?: boolean): Promise { Logger.debug(`Initializing GitHub${getGitHubSuffix(authProviderId)} authentication provider.`, 'Authentication'); - if (isEnterprise(authProviderId)) { - if (!hasEnterpriseUri()) { - Logger.debug(`GitHub Enterprise provider selected without URI.`, 'Authentication'); - return { canceled: false }; - } - } - const envResult = await this.tryInitializeFromEnvironmentToken(authProviderId); if (envResult) { return envResult; } - + const previousHub = this.getHub(authProviderId); + if (previousHub?.session && (requireScopes || getAuthSessionOptions.forceNewSession)) { + getAuthSessionOptions = { ...sessionAccountOptions(previousHub.session), ...getAuthSessionOptions }; + } if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { getAuthSessionOptions.createIfNone = false; } @@ -268,10 +261,18 @@ export class CredentialStore extends Disposable { this._scopesEnterprise = scopes; } const result = await this.getSession(authProviderId, getAuthSessionOptions, scopes, !!requireScopes); + if (this.getHub(authProviderId) !== previousHub && !isSameSession(this.getHub(authProviderId)?.session, result.session)) { + Logger.debug('Authentication changed while looking up a session.', CredentialStore.ID); + return { canceled: true }; + } usedScopes = result.scopes; session = result.session; isNew = result.isNew; } catch (e) { + if (this.getHub(authProviderId) !== previousHub) { + Logger.debug('Authentication changed while looking up a session.', CredentialStore.ID); + return { canceled: true }; + } this._scopes = oldScopes; this._scopesEnterprise = oldEnterpriseScopes; const userCanceld = (e.message === 'User did not consent to login.'); @@ -287,16 +288,14 @@ export class CredentialStore extends Disposable { } if (session) { - if (!isEnterprise(authProviderId)) { - this._sessionId = session.id; - this._accountId = session.account.id; - } else { - this._enterpriseSessionId = session.id; - this._enterpriseAccountId = session.account.id; - } let github: GitHub | undefined; try { - github = await this.createHub(session.accessToken, authProviderId); + if ((getAuthSessionOptions.account && getAuthSessionOptions.account.id !== session.account.id) + || (getAuthSessionOptions.authorizationServer && getAuthSessionOptions.authorizationServer.toString() !== session.authorizationServer?.toString())) { + throw new AuthenticationError(vscode.l10n.t('The authentication session no longer matches the requested GitHub account or server. Please sign in again.')); + } + const currentHub = this.getHub(authProviderId); + github = currentHub && isSameSession(currentHub.session, session) ? currentHub : this.createHub(session, authProviderId); } catch (e) { if ((e.message === 'Bad credentials') && !getAuthSessionOptions.forceNewSession) { Logger.debug(`Creating hub failed ${e.message}`, CredentialStore.ID); @@ -304,12 +303,16 @@ export class CredentialStore extends Disposable { getAuthSessionOptions.silent = false; return this.initialize(authProviderId, getAuthSessionOptions, scopes, requireScopes); } else { - // console.log because we need to see if we can learn more from the error object. - console.log(e); Logger.error(`Creating hub failed ${e.message}`, CredentialStore.ID); - vscode.window.showErrorMessage(vscode.l10n.t('Unable to sign in with the provided credentials')); + vscode.window.showErrorMessage(e instanceof AuthenticationError ? e.message : vscode.l10n.t('Unable to sign in with the provided credentials')); + authResult.unavailable = true; } } + if (!github && requireScopes) { + this._scopes = oldScopes; + this._scopesEnterprise = oldEnterpriseScopes; + return authResult; + } if (!isEnterprise(authProviderId)) { Logger.debug('Setting hub and scopes', CredentialStore.ID); this._githubAPI = github; @@ -319,6 +322,16 @@ export class CredentialStore extends Disposable { this._githubEnterpriseAPI = github; this._scopesEnterprise = usedScopes; } + if (github && previousHub !== github) { + this._onDidGetSession.fire(); + } + if (previousHub && previousHub !== github && !this._isSamling) { + this._onDidChangeSessions.fire({ + provider: { id: authProviderId, label: `GitHub${getGitHubSuffix(authProviderId)}` }, + accountChanged: hasAccountChanged(previousHub.session?.account.id, github?.session), + serverChanged: isEnterprise(authProviderId) && previousHub.serverUri.toString() !== github?.serverUri.toString(), + }); + } await this.saveScopesInState(); if (!this._isInitialized || (isNew && !this._isSamling)) { @@ -333,29 +346,23 @@ export class CredentialStore extends Disposable { } return authResult; } else { + this._scopes = oldScopes; + this._scopesEnterprise = oldEnterpriseScopes; Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, CredentialStore.ID); return authResult; } } private async doCreate(options: vscode.AuthenticationGetSessionOptions, additionalScopes: boolean = false): Promise { - let enterprise: AuthResult | undefined; - const initializeEnterprise = async () => { - enterprise = await this.initialize(AuthProvider.githubEnterprise, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); - }; - if (hasEnterpriseUri()) { - await initializeEnterprise(); - } else { - // Listen for changes to the enterprise URI and try again if it changes. - initBasedOnSettingChange(GITHUB_ENTERPRISE, URI, hasEnterpriseUri, initializeEnterprise, this.context.subscriptions); - } - const githubOptions = { ...options }; - if (enterprise && !enterprise.canceled) { - githubOptions.silent = true; - } + const enterpriseOptions = this.isAuthenticated(AuthProvider.githubEnterprise) ? { ...options } : { silent: true }; + const enterprise = await this.initialize(AuthProvider.githubEnterprise, enterpriseOptions, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); + const keepPublicSilent = this.isAuthenticated(AuthProvider.githubEnterprise) && !enterprise.canceled + && (!options.forceNewSession || !this.isAuthenticated(AuthProvider.github)); + const githubOptions = keepPublicSilent ? { silent: true } : { ...options }; const github = await this.initialize(AuthProvider.github, githubOptions, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); return { - canceled: github.canceled || !!(enterprise && enterprise.canceled) + canceled: github.canceled || enterprise.canceled, + unavailable: !this.isAnyAuthenticated() && (github.unavailable || enterprise.unavailable), }; } @@ -444,7 +451,7 @@ export class CredentialStore extends Disposable { } public getAccountId(authProviderId: AuthProvider): string | undefined { - return isEnterprise(authProviderId) ? this._enterpriseAccountId : this._accountId; + return this.getHub(authProviderId)?.session?.account.id; } public areScopesOld(authProviderId: AuthProvider): boolean { @@ -472,10 +479,10 @@ export class CredentialStore extends Disposable { } */ this._telemetry.sendTelemetryEvent('remoteAgent.command.auth', { - succeeded: result.canceled ? 'false' : 'true' + succeeded: result.canceled || result.unavailable ? 'false' : 'true' }); - if (result.canceled) { + if (result.canceled || result.unavailable) { return false; } return true; @@ -490,11 +497,12 @@ export class CredentialStore extends Disposable { public async getHubEnsureAdditionalScopes(authProviderId: AuthProvider): Promise { const hasScopesAlready = this.isAuthenticatedWithAdditionalScopes(authProviderId); - await this.initialize(authProviderId, { createIfNone: !hasScopesAlready }, SCOPES_WITH_ADDITIONAL, true); - if (!hasScopesAlready) { + const session = this.getHub(authProviderId)?.session; + const result = await this.initialize(authProviderId, { createIfNone: !hasScopesAlready, ...(session ? sessionAccountOptions(session) : {}) }, SCOPES_WITH_ADDITIONAL, true); + if (!result.canceled && !result.unavailable && !hasScopesAlready && this.isAuthenticatedWithAdditionalScopes(authProviderId)) { this._onDidUpgradeSession.fire(); } - return this.getHub(authProviderId); + return result.canceled || result.unavailable ? undefined : this.getHub(authProviderId); } public async getHubOrLogin(authProviderId: AuthProvider): Promise { @@ -541,7 +549,8 @@ export class CredentialStore extends Disposable { let isCanceled: boolean = false; while (retry) { try { - await this.initialize(authProviderId, sessionOptions); + const result = await this.initialize(authProviderId, sessionOptions); + isCanceled = result.canceled || !!result.unavailable; } catch (e) { Logger.error(`Login error: ${errorPrefix}: ${e}`, CredentialStore.ID); if (e instanceof Error && e.stack) { @@ -636,32 +645,38 @@ export class CredentialStore extends Disposable { } private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions, scopes: string[], requireScopes: boolean): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean, scopes: string[] }> { - const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await findExistingSession(authProviderId); - if (existingSession?.session) { - return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; - } + try { + const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await findExistingSession(authProviderId); + if (existingSession?.session) { + return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; + } - const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); - return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; + const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); + return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; + } catch (error) { + if (authProviderId === AuthProvider.githubEnterprise && error instanceof Error + && error.message === `No authentication provider '${AuthProvider.githubEnterprise}' is currently registered.`) { + Logger.appendLine('GitHub Enterprise authentication is unavailable. Set up GitHub Enterprise to sign in.', CredentialStore.ID); + return { session: undefined, isNew: false, scopes }; + } + throw error; + } } - private async createHub(token: string, authProviderId: AuthProvider): Promise { + private createHub(credentials: vscode.AuthenticationSession | string, authProviderId: AuthProvider): GitHub { + const session = typeof credentials === 'string' ? undefined : credentials; + const token = typeof credentials === 'string' ? credentials : credentials.accessToken; + const serverUri = getSessionGitHubUri(authProviderId, session); let baseUrl = 'https://api.github.com'; - let enterpriseServerUri: vscode.Uri | undefined; + const enterpriseServerUri = isEnterprise(authProviderId) ? serverUri : undefined; + const hostname = new URL(serverUri.toString()).hostname.toLowerCase(); + const isGhe = hostname === 'ghe.com' || hostname.endsWith('.ghe.com'); Logger.appendLine(`Creating hub for ${isEnterprise(authProviderId) ? 'enterprise' : '.com'}`, CredentialStore.ID); - if (isEnterprise(authProviderId)) { - enterpriseServerUri = getEnterpriseUri(); - } - - const isGhe = enterpriseServerUri?.authority.endsWith('ghe.com'); - if (enterpriseServerUri) { Logger.appendLine(`Enterprise server authority ${enterpriseServerUri.authority}`, CredentialStore.ID); - if (isGhe) { - baseUrl = `${enterpriseServerUri.scheme}://api.${enterpriseServerUri.authority}`; - } else { - baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api/v3`; - } + baseUrl = isGhe + ? enterpriseServerUri.with({ authority: `api.${enterpriseServerUri.authority}` }).toString().replace(/\/$/, '') + : vscode.Uri.joinPath(enterpriseServerUri, 'api/v3').toString(); } let fetchCore: ((url: string, options: { headers?: Record }) => any) | undefined; @@ -686,11 +701,7 @@ export class CredentialStore extends Disposable { baseUrl: baseUrl, }); - let graphQLBaseUrl = baseUrl; - if (enterpriseServerUri && !isGhe) { - graphQLBaseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api`; - } - + const graphQLBaseUrl = enterpriseServerUri && !isGhe ? vscode.Uri.joinPath(enterpriseServerUri, 'api').toString() : baseUrl; const graphql = new ApolloClient({ link: link(graphQLBaseUrl, token || ''), cache: new InMemoryCache(), @@ -702,9 +713,13 @@ export class CredentialStore extends Disposable { }); const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId), (_e) => { - void this.handleAuthError(authProviderId); + if (this.getHub(authProviderId) === github) { + void this.handleAuthError(authProviderId); + } }); const github: GitHub = { + serverUri, + session, octokit: new LoggingOctokit(octokit, rateLogger), graphql: new LoggingApolloClient(graphql, rateLogger), }; diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index ae5063cc0e..d32551b114 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -244,7 +244,7 @@ export class FolderRepositoryManager extends Disposable { ) { super(); this._githubRepositories = []; - this._githubManager = new GitHubManager(this.telemetry); + this._githubManager = new GitHubManager(this._credentialStore, this.telemetry); this._register( vscode.workspace.onDidChangeConfiguration(async e => { @@ -469,7 +469,7 @@ export class FolderRepositoryManager extends Disposable { let isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise); if ((dotComCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.github)) { // good - } else if ((enterpriseCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + } else if ((enterpriseCount > 0) && activeRemotes.some(remote => this.matchesEnterpriseSession(remote))) { // also good } else if (isAuthenticated && ((dotComCount > 0) || (enterpriseCount > 0))) { // Not good. We have a mismatch between auth type and server type. @@ -479,6 +479,11 @@ export class FolderRepositoryManager extends Disposable { return isAuthenticated; } + private matchesEnterpriseSession(remote: Remote): boolean { + const hub = this._credentialStore.getHub(AuthProvider.githubEnterprise); + return remote.isEnterprise && !!hub && remote.matchesServerUri(hub.serverUri); + } + get state(): ReposManagerState { return this._state; } @@ -530,6 +535,7 @@ export class FolderRepositoryManager extends Disposable { const authenticatedRemotes = activeRemotes.filter(remote => this._credentialStore.isAuthenticated(remote.authProviderId) + && (!remote.isEnterprise || this.matchesEnterpriseSession(remote)) && !this._inaccessibleRepos.has(`${remote.owner.toLowerCase()}/${remote.repositoryName.toLowerCase()}`) ); for (const remote of authenticatedRemotes) { @@ -736,6 +742,14 @@ export class FolderRepositoryManager extends Disposable { if (!accountId) { return undefined; } + if (repo.remote.isEnterprise) { + const hub = this._credentialStore.getHub(AuthProvider.githubEnterprise); + if (!hub || !repo.remote.matchesServerUri(hub.serverUri)) { + Logger.debug('Enterprise user cache is unavailable for this authentication session.', this.id); + return undefined; + } + return vscode.Uri.joinPath(this.context.globalStorageUri, userKind, encodeURIComponent(repo.remote.authProviderId), encodeURIComponent(hub.serverUri.toString()), encodeURIComponent(accountId)); + } return vscode.Uri.joinPath(this.context.globalStorageUri, userKind, encodeURIComponent(repo.remote.authProviderId), encodeURIComponent(accountId)); } @@ -3005,12 +3019,13 @@ export class FolderRepositoryManager extends Disposable { } } - public findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string }): GitHubRepository | undefined { + public findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string, normalizedHost?: string }): GitHubRepository | undefined { return this._githubRepositories.find( r => (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) - && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)), + && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)) + && (!remote.normalizedHost || r.remote.normalizedHost === remote.normalizedHost), ); } @@ -3041,7 +3056,7 @@ export class FolderRepositoryManager extends Disposable { } // Use a bulkhead/semaphore to ensure that we don't create multiple GitHubRepositories for the same remote at the same time. return this._createGitHubRepositoryBulkhead.execute(async () => { - return this.findExistingGitHubRepository({ owner: remote.owner, repositoryName: remote.repositoryName, remoteName: ignoreRemoteName ? undefined : remote.remoteName }) ?? + return this.findExistingGitHubRepository({ owner: remote.owner, repositoryName: remote.repositoryName, remoteName: ignoreRemoteName ? undefined : remote.remoteName, normalizedHost: remote.normalizedHost }) ?? await this.createAndAddGitHubRepository(remote, credentialStore, silent); }); } diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 2c657db56f..9428a7205b 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -218,6 +218,9 @@ export class GitHubRepository extends Disposable { public readonly onDidChangePullRequests: vscode.Event = this._onDidChangePullRequests.event; public get hub(): GitHub { + if (this._hub && this.remote.isEnterprise && (!this.authMatchesServer || !this.remote.matchesServerUri(this._hub.serverUri))) { + throw new AuthenticationError(vscode.l10n.t('The authentication session no longer matches this GitHub repository. Refresh the repository or sign in again.')); + } if (!this._hub) { if (!this._initialized) { throw new Error('Call ensure() before accessing this property.'); @@ -295,6 +298,14 @@ export class GitHubRepository extends Disposable { super(); GitHubRepository._allRepoIds.add(this._id); this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, defaultSchema); + this._register(this._credentialStore.onDidChangeSessions(e => { + if (e.provider.id === this.remote.authProviderId) { + this._hub = this._credentialStore.getHub(this.remote.authProviderId); + if (e.accountChanged || e.serverChanged) { + this._metadata = undefined; + } + } + })); // kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring if (!silent) { this.ensureCommentsController(); @@ -304,8 +315,9 @@ export class GitHubRepository extends Disposable { get authMatchesServer(): boolean { if ((this.remote.githubServerType === GitHubServerType.GitHubDotCom) && this._credentialStore.isAuthenticated(AuthProvider.github)) { return true; - } else if ((this.remote.githubServerType === GitHubServerType.Enterprise) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { - return true; + } else if (this.remote.githubServerType === GitHubServerType.Enterprise) { + const hub = this._credentialStore.getHub(AuthProvider.githubEnterprise); + return !!hub && this.remote.matchesServerUri(hub.serverUri); } else { // Not good. We have a mismatch between auth type and server type. return false; @@ -477,7 +489,6 @@ export class GitHubRepository extends Disposable { async ensure(additionalScopes: boolean = false): Promise { this._initialized = true; - const oldHub = this._hub; if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { // We need auth now. (ex., a PR is already checked out) // We can no longer wait until later for login to be done @@ -493,7 +504,15 @@ export class GitHubRepository extends Disposable { } } - if (oldHub !== this._hub) { + if (this._hub && !this.remote.matchesServerUri(this._hub.serverUri)) { + this._hub = undefined; + const error = new AuthenticationError(vscode.l10n.t('The selected authentication session does not match the GitHub server for this repository.')); + Logger.warn(error.message, this.id); + throw error; + } + + // A session event may have already refreshed the hub before ensure(). + if (this._hub) { if (this._areQueriesLimited || this._credentialStore.areScopesOld(this.remote.authProviderId) || (this.remote.authProviderId === AuthProvider.githubEnterprise)) { this._areQueriesLimited = true; this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, limitedSchema.default); diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 2d3f66c77d..a99911b460 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -28,7 +28,7 @@ import { IssueOverviewPanel, panelKey } from './issueOverview'; import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks'; -import { getEnterpriseUri, getIssueOrURLExpression, parseIssueExpressionOutput, parseReviewers, processDiffLinks, processPermalinks } from './utils'; +import { getIssueOrURLExpression, parseIssueExpressionOutput, parseReviewers, processDiffLinks, processPermalinks } from './utils'; import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, SubmitReviewArgs, UnresolvedIdentity } from './views'; import { debounce } from '../common/async'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; @@ -447,7 +447,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - const enterpriseUri = pullRequest.remote.isEnterprise ? getEnterpriseUri() : undefined; + const enterpriseUri = pullRequest.remote.isEnterprise ? pullRequest.githubRepository.hub.serverUri : undefined; const issueOrUrlExpression = getIssueOrURLExpression(enterpriseUri); return Promise.all((pullRequest.closingIssues ?? []).map(async issue => { const parsed = parseIssueExpressionOutput(issue.url.match(issueOrUrlExpression)); diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index a147f42dc5..bb8e8bf98e 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -308,7 +308,8 @@ export class RepositoriesManager extends Disposable { else if (!hasEnterpriseUri() && (dotComRemotes.length === 0) && (enterpriseRemotes.length > 0)) { const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('It looks like you might be using GitHub Enterprise. Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', enterpriseRemotes[0].normalizedHost), { modal: true }, yes, vscode.l10n.t('No, use GitHub.com')); - if (promptResult === yes) { + enterprise = promptResult === yes; + if (enterprise) { await setEnterpriseUri(enterpriseRemotes[0].normalizedHost); } else if (promptResult === undefined) { return false; @@ -317,11 +318,12 @@ export class RepositoriesManager extends Disposable { let githubEnterprise; const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); - if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { + const preferEnterprise = enterprise ?? (hasNonDotComRemote && (dotComRemotes.length === 0 || this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise))); + if (preferEnterprise) { githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); } let github; - if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { + if (!githubEnterprise && (!preferEnterprise || (enterprise !== true && enterpriseRemotes.length === 0))) { github = await this._credentialStore.login(AuthProvider.github); } return !!github || !!githubEnterprise; diff --git a/src/issues/util.ts b/src/issues/util.ts index 3e54082abe..0adcc38ef8 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -11,11 +11,12 @@ import { Ref, Remote, Repository, UpstreamRef } from '../api/api'; import { GitApiImpl } from '../api/api1'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; +import { parseRemote } from '../common/remote'; import { fromReviewUri, Schemes } from '../common/uri'; import { FolderRepositoryManager, NoGitHubReposError, PullRequestDefaults } from '../github/folderRepositoryManager'; import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { getEnterpriseUri, getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; +import { getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { ReviewManager } from '../view/reviewManager'; export const USER_EXPRESSION: RegExp = /\@([^\s]+)/; @@ -388,12 +389,10 @@ export async function createGithubPermalink( } export function getUpstreamOrigin(upstream: Remote, resultHost: string = 'github.com') { - const enterpriseUri = getEnterpriseUri(); - let fetchUrl = upstream.fetchUrl; - if (enterpriseUri && fetchUrl) { - const protocol = new Protocol(fetchUrl); - if (protocol.host.startsWith(enterpriseUri.authority) || !protocol.host.includes('github.com')) { - resultHost = enterpriseUri.authority; + if (upstream.fetchUrl) { + const remote = parseRemote(upstream.name, upstream.fetchUrl); + if (remote?.isEnterprise) { + return remote.normalizedHost; } } return `https://${resultHost}`; @@ -571,4 +570,3 @@ export function getRootUriFromScmInputUri(uri: vscode.Uri): vscode.Uri | undefin export function escapeMarkdown(text: string): string { return text.replace(/([_~*])/g, '\\$1'); } - diff --git a/src/lm/tools/toolsUtils.ts b/src/lm/tools/toolsUtils.ts index 07692d35d3..414248f4ab 100644 --- a/src/lm/tools/toolsUtils.ts +++ b/src/lm/tools/toolsUtils.ts @@ -8,7 +8,6 @@ import { AuthenticationError, AuthProvider } from '../../common/authentication'; import { CredentialStore, GitHub } from '../../github/credentials'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { RepositoriesManager } from '../../github/repositoriesManager'; -import { hasEnterpriseUri } from '../../github/utils'; export interface IToolCall { tool: vscode.LanguageModelToolInformation; @@ -92,7 +91,7 @@ export abstract class RepoToolBase extends ToolBase { protected getGitHub(): GitHub | undefined { let authProvider: AuthProvider | undefined; - if (this.credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + if (this.credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { authProvider = AuthProvider.githubEnterprise; } else if (this.credentialStore.isAuthenticated(AuthProvider.github)) { authProvider = AuthProvider.github; diff --git a/src/notifications/notificationsProvider.ts b/src/notifications/notificationsProvider.ts index d3482ac9ec..251603f523 100644 --- a/src/notifications/notificationsProvider.ts +++ b/src/notifications/notificationsProvider.ts @@ -15,7 +15,7 @@ import { Issue, Notification, NotificationSubjectType } from '../github/interfac import { IssueModel } from '../github/issueModel'; import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { hasEnterpriseUri, parseNotification } from '../github/utils'; +import { parseNotification } from '../github/utils'; import { concatAsyncIterable } from '../lm/tools/toolsUtils'; export interface INotifications { @@ -41,7 +41,7 @@ export class NotificationsProvider extends Disposable { ) { super(); const setAuthProvider = () => { - if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { this._authProvider = AuthProvider.githubEnterprise; } else if (_credentialStore.isAuthenticated(AuthProvider.github)) { this._authProvider = AuthProvider.github; diff --git a/src/test/common/remote.test.ts b/src/test/common/remote.test.ts index bafbfef17f..681ff38cf1 100644 --- a/src/test/common/remote.test.ts +++ b/src/test/common/remote.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { default as assert } from 'assert'; +import { Uri } from 'vscode'; import { AuthProvider, GitHubServerType } from '../../common/authentication'; import { GitHubRemote, parseRemote, parseRepositoryRemotesAsync } from '../../common/remote'; import { MockRepository } from '../mocks/mockRepository'; @@ -26,6 +27,36 @@ describe('GitHubRemote', () => { assert.strictEqual(githubRemote.authProviderId, AuthProvider.github); }); + + it('classifies unconfigured enterprise remotes without reading settings', () => { + const remote = parseRemote('origin', 'https://host-b.example/owner/repo.git'); + assert.ok(remote); + assert.strictEqual(remote.authProviderId, AuthProvider.githubEnterprise); + }); + + it('matches enterprise deployments including their paths and ports', () => { + const remote = parseRemote('origin', 'https://host-b.example:8443/deployment/owner/repo.git'); + assert.ok(remote); + assert.strictEqual(remote.normalizedHost, 'https://host-b.example:8443/deployment'); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://host-b.example:8443/deployment')), true); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://host-a.example:8443/deployment')), false); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://host-b.example:9443/deployment')), false); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://host-b.example:8443/another')), false); + }); + + it('matches SSH remotes to the session deployment without treating SSH ports as API ports', () => { + const remote = parseRemote('origin', 'git@host-b.example:owner/repo.git'); + assert.ok(remote); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://host-b.example:8443/deployment')), true); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://host-a.example')), false); + }); + + it('preserves public SSH host routing', () => { + const remote = parseRemote('origin', 'git@ssh.github.com:owner/repo.git'); + assert.ok(remote); + assert.strictEqual(remote.authProviderId, AuthProvider.github); + assert.strictEqual(remote.matchesServerUri(Uri.parse('https://github.com')), true); + }); }); describe('parseRepositoryRemotesAsync', () => { diff --git a/src/test/github/authIssuers.test.ts b/src/test/github/authIssuers.test.ts new file mode 100644 index 0000000000..d6f352ed4b --- /dev/null +++ b/src/test/github/authIssuers.test.ts @@ -0,0 +1,336 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { gql } from 'apollo-boost'; +import { Response } from 'cross-fetch'; +import { createSandbox, SinonSandbox, SinonStub } from 'sinon'; +import * as vscode from 'vscode'; +import { GitHubManager } from '../../authentication/githubServer'; +import { AuthenticationError, AuthProvider, GitHubServerType } from '../../common/authentication'; +import { GitHubRemote, parseRemote } from '../../common/remote'; +import { GithubRemoteSourceProvider } from '../../gitExtensionIntegration'; +import { CredentialStore, CredentialStoreSessionsChangeEvent, GitHub } from '../../github/credentials'; +import { GitHubRepository } from '../../github/githubRepository'; +import { LoggingOctokit } from '../../github/loggingOctokit'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import * as utils from '../../github/utils'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockTelemetry } from '../mocks/mockTelemetry'; + +const scopes = ['read:user', 'user:email', 'repo', 'workflow']; +const additionalScopes = [...scopes, 'project', 'read:org']; +const hostA = 'https://host-a.example'; +const hostB = 'https://host-b.example'; +const httpLink: typeof import('apollo-link-http') = require('apollo-link-http'); +const viewerQuery = gql`query { viewer { login } }`; + +function session(issuer?: string, overrides: Partial = {}): vscode.AuthenticationSession { + return { + id: 'session', + account: { id: 'account', label: 'account' }, + accessToken: 'test-token', + scopes, + authorizationServer: issuer ? vscode.Uri.parse(issuer) : undefined, + ...overrides, + }; +} + +describe('authIssuers', function () { + let sinon: SinonSandbox; + let context: MockExtensionContext; + let store: CredentialStore; + let selected: Map; + let getSession: SinonStub; + let apiCall: SinonStub; + let graphqlFetch: SinonStub; + let configuration: SinonStub; + let showError: SinonStub; + let changes: CredentialStoreSessionsChangeEvent[]; + let sessionEvents: vscode.EventEmitter; + + function enterpriseHub(): GitHub { + const hub = store.getHub(AuthProvider.githubEnterprise); + assert.ok(hub); + return hub; + } + + async function refreshSession(value?: vscode.AuthenticationSession, provider: AuthProvider = AuthProvider.githubEnterprise): Promise { + if (value) { + selected.set(provider, value); + } else { + selected.delete(provider); + } + sessionEvents.fire({ provider: { id: provider, label: 'GitHub' } }); + await new Promise(resolve => setImmediate(resolve)); + } + + beforeEach(function () { + sinon = createSandbox(); + selected = new Map(); + changes = []; + sinon.stub(process, 'env').value({ ...process.env, GITHUB_OAUTH_TOKEN: undefined, GITHUB_TEST_SERVER: undefined }); + const settings: vscode.WorkspaceConfiguration = { + get: sinon.stub().callsFake((key: string, defaultValue?: unknown) => key === 'uri' ? hostA : defaultValue), + has: sinon.stub().returns(true), + inspect: sinon.stub().returns(undefined), + update: sinon.stub().rejects(new Error('Tests must not write settings')), + }; + configuration = sinon.stub(vscode.workspace, 'getConfiguration').returns(settings); + getSession = sinon.stub(vscode.authentication, 'getSession').callsFake(async (provider, requestedScopes, options = {}) => { + assert.ok(Array.isArray(requestedScopes), 'Expected a GitHub scopes request'); + const candidate = selected.get(provider); + if (!candidate || !requestedScopes.every(scope => candidate.scopes.includes(scope)) + || (options.account && options.account.id !== candidate.account.id) + || (options.authorizationServer && options.authorizationServer.toString() !== candidate.authorizationServer?.toString())) { + return undefined; + } + return candidate; + }); + sessionEvents = new vscode.EventEmitter(); + sinon.stub(vscode.authentication, 'onDidChangeSessions').value(sessionEvents.event); + showError = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); + apiCall = sinon.stub(LoggingOctokit.prototype, 'call').resolves({ + data: { login: 'octocat', node_id: 'user', html_url: `${hostB}/octocat`, avatar_url: '', type: 'User' }, + }); + graphqlFetch = sinon.stub().callsFake(async () => new Response(JSON.stringify({ data: { viewer: { login: 'octocat' } } }), { + status: 200, headers: { 'content-type': 'application/json' }, + })); + const createHttpLink = httpLink.createHttpLink; + sinon.stub(httpLink, 'createHttpLink').callsFake(options => createHttpLink({ ...options, fetch: graphqlFetch })); + context = new MockExtensionContext(); + store = new CredentialStore(new MockTelemetry(), context); + context.subscriptions.push(sessionEvents, store, store.onDidChangeSessions(e => changes.push(e))); + }); + + afterEach(function () { + context.dispose(); + sinon.restore(); + }); + + for (const deployment of [ + { host: hostB, rest: `${hostB}/api/v3`, graphql: `${hostB}/api/graphql` }, + { host: `${hostB}:8443/deployment`, rest: `${hostB}:8443/deployment/api/v3`, graphql: `${hostB}:8443/deployment/api/graphql` }, + { host: 'https://tenant.ghe.com:8443/deployment', rest: 'https://api.tenant.ghe.com:8443/deployment', graphql: 'https://api.tenant.ghe.com:8443/deployment/graphql' }, + ]) { + it(`routes the session token to ${deployment.host} while host A remains configured`, async function () { + const selectedSession = session(`${deployment.host}/login/oauth`); + selected.set(AuthProvider.githubEnterprise, selectedSession); + await store.create({ silent: true }); + const hub = enterpriseHub(); + + assert.strictEqual(hub.session, selectedSession); + assert.strictEqual(hub.serverUri.toString(), vscode.Uri.parse(deployment.host).toString()); + assert.strictEqual(hub.octokit.api.request.endpoint('GET /user').url, `${deployment.rest}/user`); + assert.deepStrictEqual(await hub.octokit.api.auth(), { type: 'token', token: selectedSession.accessToken, tokenType: 'oauth' }); + await hub.graphql.query({ query: viewerQuery }); + assert.strictEqual(graphqlFetch.firstCall.args[0], deployment.graphql); + assert.strictEqual(graphqlFetch.firstCall.args[1].headers.authorization, `Bearer ${selectedSession.accessToken}`); + assert.strictEqual(configuration.calledWith('github-enterprise'), false); + assert.strictEqual(showError.called, false); + }); + } + + for (const issuer of [undefined, 'https://github.com/login/oauth']) { + it(`preserves public routing ${issuer ? 'with' : 'without'} issuer metadata`, async function () { + selected.set(AuthProvider.github, session(issuer)); + await store.create({ silent: true }); + const hub = store.getHub(AuthProvider.github); + assert.ok(hub); + assert.strictEqual(hub.octokit.api.request.endpoint('GET /user').url, 'https://api.github.com/user'); + await hub.graphql.query({ query: viewerQuery }); + assert.strictEqual(graphqlFetch.firstCall.args[0], 'https://api.github.com/graphql'); + assert.strictEqual(store.isAuthenticated(AuthProvider.githubEnterprise), false); + assert.strictEqual(showError.called, false); + }); + } + + it('preserves public environment-token authentication', async function () { + process.env.GITHUB_OAUTH_TOKEN = 'public-test-token'; + await store.create({ silent: true }); + const hub = store.getHub(AuthProvider.github); + assert.ok(hub); + assert.strictEqual(hub.session, undefined); + assert.strictEqual(hub.octokit.api.request.endpoint('GET /user').url, 'https://api.github.com/user'); + assert.strictEqual(getSession.calledWith(AuthProvider.github), false); + }); + + for (const issuer of [undefined, `${hostB}/api/v3`]) { + it(`reports unavailable enterprise provenance (${issuer ?? 'missing'}) without guessing a host`, async function () { + selected.set(AuthProvider.githubEnterprise, session(issuer, { account: { id: hostB, label: hostB } })); + await store.create({ silent: true }); + assert.strictEqual(store.isAuthenticated(AuthProvider.githubEnterprise), false); + assert.strictEqual(apiCall.called, false); + assert.strictEqual(showError.calledOnce, true); + assert.match(showError.firstCall.args[0], /GitHub Enterprise is unavailable.*authorization server/); + assert.strictEqual(configuration.calledWith('github-enterprise'), false); + }); + } + + it('replaces cached clients for issuer or token changes, not unchanged sessions', async function () { + const original = session(`${hostA}/login/oauth`); + selected.set(AuthProvider.githubEnterprise, original); + await store.create({ silent: true }); + const firstHub = enterpriseHub(); + await refreshSession(original); + assert.strictEqual(enterpriseHub(), firstHub); + assert.strictEqual(changes.length, 0); + + await refreshSession(session(`${hostB}/login/oauth`)); + const secondHub = enterpriseHub(); + assert.notStrictEqual(secondHub, firstHub); + assert.strictEqual(secondHub.serverUri.toString(), vscode.Uri.parse(hostB).toString()); + await refreshSession(session(`${hostB}/login/oauth`, { accessToken: 'refreshed-token' })); + assert.notStrictEqual(enterpriseHub(), secondHub); + assert.deepStrictEqual(changes.map(e => ({ account: e.accountChanged, server: e.serverChanged })), [ + { account: false, server: true }, { account: false, server: false }, + ]); + await refreshSession(); + assert.strictEqual(store.isAuthenticated(AuthProvider.githubEnterprise), false); + }); + + it('does not overwrite a newer selected connection with an older lookup', async function () { + let resolveOld!: (value: vscode.AuthenticationSession) => void; + getSession.onFirstCall().returns(new Promise(resolve => resolveOld = resolve)); + const pending = store.create({ silent: true }); + await refreshSession(session(`${hostB}/login/oauth`)); + resolveOld(session(`${hostA}/login/oauth`)); + await pending; + assert.strictEqual(enterpriseHub().serverUri.toString(), vscode.Uri.parse(hostB).toString()); + }); + + it('keeps scope upgrades on the selected account and issuer', async function () { + const original = session(`${hostB}/login/oauth`); + selected.set(AuthProvider.githubEnterprise, original); + await store.create({ silent: true }); + const upgraded = session(`${hostB}/login/oauth`, { id: 'upgraded', scopes: additionalScopes }); + selected.set(AuthProvider.githubEnterprise, upgraded); + getSession.resetHistory(); + const hub = await store.getHubEnsureAdditionalScopes(AuthProvider.githubEnterprise); + assert.strictEqual(hub?.session, upgraded); + assert.deepStrictEqual(getSession.firstCall.args, [AuthProvider.githubEnterprise, additionalScopes, { + createIfNone: true, account: original.account, authorizationServer: original.authorizationServer, + }]); + }); + + it('preserves the connection when a scope upgrade is canceled or returns a different issuer', async function () { + selected.set(AuthProvider.githubEnterprise, session(`${hostB}/login/oauth`)); + await store.create({ silent: true }); + const hub = enterpriseHub(); + const upgrade = getSession.withArgs(AuthProvider.githubEnterprise, additionalScopes); + upgrade.rejects(new Error('User did not consent to login.')); + await assert.rejects(store.getHubEnsureAdditionalScopes(AuthProvider.githubEnterprise), /User did not consent/); + assert.strictEqual(enterpriseHub(), hub); + upgrade.resolves(session(`${hostA}/login/oauth`, { scopes: additionalScopes })); + assert.strictEqual(await store.getHubEnsureAdditionalScopes(AuthProvider.githubEnterprise), undefined); + assert.strictEqual(enterpriseHub(), hub); + assert.strictEqual(store.isAuthenticatedWithAdditionalScopes(AuthProvider.githubEnterprise), false); + assert.match(showError.firstCall.args[0], /no longer matches.*account or server/); + }); + + it('matches repositories and server discovery to the selected deployment', async function () { + selected.set(AuthProvider.githubEnterprise, session(`${hostB}/login/oauth`)); + await store.create({ silent: true }); + const manager = new GitHubManager(store); + assert.strictEqual(await manager.isGitHub(vscode.Uri.parse(`${hostB}/owner/repo`)), GitHubServerType.Enterprise); + for (const host of [hostA, hostB]) { + const remote = parseRemote('origin', `${host}/owner/repo`); + assert.ok(remote); + const repo = new GitHubRepository(1, GitHubRemote.remoteAsGitHub(remote, GitHubServerType.Enterprise), context.extensionUri, store, new MockTelemetry(), true); + context.subscriptions.push(repo); + if (host === hostA) { + await assert.rejects(repo.ensure(), /does not match the GitHub server/); + } else { + await repo.ensure(); + assert.strictEqual(repo.hub, enterpriseHub()); + await refreshSession(session(`${hostA}/login/oauth`)); + assert.throws(() => repo.hub, AuthenticationError); + } + } + }); + + it('does not reuse repository-search results from another connection', async function () { + selected.set(AuthProvider.githubEnterprise, session(`${hostA}/login/oauth`)); + await store.create({ silent: true }); + const provider = new GithubRemoteSourceProvider(store, AuthProvider.githubEnterprise); + apiCall.onCall(apiCall.callCount).resolves({ data: [{ full_name: 'owner/repo', clone_url: `${hostA}/owner/repo`, description: '' }] }); + assert.strictEqual((await provider.getRemoteSources()).length, 1); + await refreshSession(session(`${hostB}/login/oauth`)); + apiCall.onCall(apiCall.callCount).resolves({ data: { items: [] } }); + assert.deepStrictEqual(await provider.getRemoteSources('query'), []); + }); + + describe('selected review feedback', function () { + for (const issuer of [undefined, `${hostB}/login/oauth`]) { + it(`reports ${issuer ? 'successful' : 'unavailable'} Copilot authentication accurately`, async function () { + const telemetry = sinon.spy(MockTelemetry.prototype, 'sendTelemetryEvent'); + sinon.stub(vscode.commands, 'executeCommand').resolves(true); + selected.set(AuthProvider.githubEnterprise, session(issuer)); + + assert.strictEqual(await store.tryPromptForCopilotAuth(), !!issuer); + assert.ok(telemetry.calledWithExactly('remoteAgent.command.auth', { succeeded: issuer ? 'true' : 'false' })); + }); + } + + for (const provider of [AuthProvider.github, AuthProvider.githubEnterprise]) { + it(`refreshes ${provider} repository clients without discarding same-account metadata`, async function () { + const host = provider === AuthProvider.github ? 'https://github.com' : hostB; + const type = provider === AuthProvider.github ? GitHubServerType.GitHubDotCom : GitHubServerType.Enterprise; + selected.set(provider, session(`${host}/login/oauth`)); + await store.create({ silent: true }); + const remote = parseRemote('origin', `${host}/owner/repo`); + assert.ok(remote); + const repo = new GitHubRepository(1, GitHubRemote.remoteAsGitHub(remote, type), context.extensionUri, store, new MockTelemetry(), true); + context.subscriptions.push(repo); + await repo.ensure(); + const metadata = await repo.getMetadata(); + + const refreshed = session(`${host}/login/oauth`, { accessToken: 'refreshed-token' }); + await refreshSession(refreshed, provider); + assert.strictEqual(repo.hub, store.getHub(provider)); + assert.strictEqual(await repo.getMetadata(), metadata); + await repo.query({ query: viewerQuery }); + assert.strictEqual(graphqlFetch.lastCall.args[1].headers.authorization, 'Bearer refreshed-token'); + + selected.set(provider, { ...refreshed, accessToken: 'upgraded-token', scopes: additionalScopes }); + await store.getHubEnsureAdditionalScopes(provider); + assert.strictEqual(repo.hub, store.getHub(provider)); + assert.strictEqual(await repo.getMetadata(), metadata); + await repo.ensure(); + if (provider === AuthProvider.github) { + assert.ok(repo.schema.GetRepoProjects); + } + + await refreshSession(session(`${host}/login/oauth`, { account: { id: 'other', label: 'other' } }), provider); + assert.notStrictEqual(await repo.getMetadata(), metadata); + }); + } + + for (const remoteKind of ['enterprise', 'unknown', 'public']) { + it(`uses the intended login fallback for ${remoteKind} remotes`, async function () { + selected.set(AuthProvider.github, session()); + await store.create({ silent: true }); + const publicHub = store.getHub(AuthProvider.github); + assert.ok(publicHub); + const remote = parseRemote('origin', `${hostB}/owner/repo`); + assert.ok(remote); + sinon.stub(utils, 'findDotComAndEnterpriseRemotes').resolves({ + dotComRemotes: remoteKind === 'public' ? [remote] : [], + enterpriseRemotes: remoteKind === 'enterprise' ? [remote] : [], + unknownRemotes: remoteKind === 'unknown' ? [remote] : [], + }); + const login = sinon.stub(store, 'login').resolves(undefined); + login.withArgs(AuthProvider.github).resolves(publicHub); + const repositories = new RepositoriesManager(store, new MockTelemetry()); + context.subscriptions.push(repositories); + + assert.strictEqual(await repositories.authenticate(), remoteKind !== 'enterprise'); + assert.deepStrictEqual(login.getCalls().map(call => call.args[0]), remoteKind === 'public' + ? [AuthProvider.github] + : remoteKind === 'enterprise' ? [AuthProvider.githubEnterprise] : [AuthProvider.githubEnterprise, AuthProvider.github]); + }); + } + }); +}); diff --git a/src/test/github/credentials.test.ts b/src/test/github/credentials.test.ts index 36dbdb31b0..8e2034e33f 100644 --- a/src/test/github/credentials.test.ts +++ b/src/test/github/credentials.test.ts @@ -168,6 +168,7 @@ describe('CredentialStore', function () { const telemetry = new MockTelemetry(); const credentialStore = new CredentialStore(telemetry, new MockExtensionContext()); const github: GitHub = { + serverUri: vscode.Uri.parse('https://github.com'), octokit: new LoggingOctokit(new Octokit(), new RateLogger(telemetry, false)), graphql: {} as LoggingApolloClient, }; diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 82c5a1e9c2..da8909a26f 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -18,9 +18,10 @@ import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { GitApiImpl, RefType } from '../../api/api1'; import { CredentialStore } from '../../github/credentials'; +import { LoggingOctokit } from '../../github/loggingOctokit'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { commands, env, MessageItem, MessageOptions, Uri, window, workspace } from 'vscode'; -import { GitHubServerType } from '../../common/authentication'; +import { AuthProvider, GitHubServerType } from '../../common/authentication'; import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; import { RepositoriesManager } from '../../github/repositoriesManager'; import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; @@ -155,6 +156,38 @@ describe('PullRequestManager', function () { assert.ok(firstAccountLocation?.toString().includes('github')); assert.ok(firstAccountLocation?.toString().includes('first-account')); }); + + it('separates persisted enterprise user caches by session deployment even for the same account ID', function () { + const firstUrl = 'https://host-a.example/owner/repo'; + const secondUrl = 'https://host-b.example:8443/deployment/owner/repo'; + const firstRepo = new GitHubRepository(1, new GitHubRemote('origin', firstUrl, new Protocol(firstUrl), GitHubServerType.Enterprise), repository.rootUri, manager.credentialStore, telemetry, true); + const secondRepo = new GitHubRepository(2, new GitHubRemote('origin', secondUrl, new Protocol(secondUrl), GitHubServerType.Enterprise), repository.rootUri, manager.credentialStore, telemetry, true); + sinon.stub(manager.credentialStore, 'getAccountId').returns('same-account'); + sinon.stub(LoggingOctokit.prototype, 'call').resolves({ + data: { login: 'user', node_id: 'user', html_url: firstUrl, avatar_url: '', type: 'User' }, + }); + const getHub = sinon.stub(manager.credentialStore, 'getHub'); + const hubFor = (deployment: string) => manager.credentialStore['createHub']({ + id: 'session', + account: { id: 'same-account', label: 'account' }, + accessToken: 'test-token', + scopes: [], + authorizationServer: Uri.parse(`${deployment}/login/oauth`), + }, AuthProvider.githubEnterprise); + getHub.returns(hubFor('https://host-a.example')); + const firstCache = manager['getAccountCacheLocation']('assignableUsers', firstRepo); + getHub.returns(hubFor('https://host-b.example:8443/deployment')); + const secondCache = manager['getAccountCacheLocation']('assignableUsers', secondRepo); + + assert.ok(firstCache); + assert.ok(secondCache); + assert.notStrictEqual(firstCache.toString(), secondCache.toString()); + assert.strictEqual(manager['getAccountCacheLocation']('assignableUsers', firstRepo), undefined); + getHub.returns(undefined); + assert.strictEqual(manager['getAccountCacheLocation']('assignableUsers', secondRepo), undefined); + firstRepo.dispose(); + secondRepo.dispose(); + }); }); describe('getPullRequestDefaults', function () { diff --git a/src/test/issues/issuesUtils.test.ts b/src/test/issues/issuesUtils.test.ts index 318d85d1a8..6af1f868f4 100644 --- a/src/test/issues/issuesUtils.test.ts +++ b/src/test/issues/issuesUtils.test.ts @@ -6,8 +6,20 @@ import { default as assert } from 'assert'; import * as vscode from 'vscode'; import { getIssueOrURLExpression, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../../github/utils'; +import { getUpstreamOrigin } from '../../issues/util'; describe('Issues utilities', function () { + it('derives enterprise links from the upstream instead of enterprise configuration', function () { + assert.strictEqual(getUpstreamOrigin({ name: 'origin', fetchUrl: 'https://host-b.example:8443/deployment/owner/repo.git', isReadOnly: false }), + 'https://host-b.example:8443/deployment'); + assert.strictEqual(getUpstreamOrigin({ name: 'origin', fetchUrl: 'git@host-b.example:owner/repo.git', isReadOnly: false }), + 'https://host-b.example'); + }); + + it('preserves static public share-link destinations', function () { + assert.strictEqual(getUpstreamOrigin({ name: 'origin', fetchUrl: 'git@github.com:owner/repo.git', isReadOnly: false }, 'github.dev'), 'https://github.dev'); + }); + it('regular expressions', async function () { const issueNumber = '#1234'; const issueNumberParsed = parseIssueExpressionOutput(issueNumber.match(ISSUE_OR_URL_EXPRESSION)); diff --git a/src/test/mocks/mockGitHubRepository.ts b/src/test/mocks/mockGitHubRepository.ts index 4bff09d7e9..8b36f70cfc 100644 --- a/src/test/mocks/mockGitHubRepository.ts +++ b/src/test/mocks/mockGitHubRepository.ts @@ -33,6 +33,7 @@ export class MockGitHubRepository extends GitHubRepository { this.queryProvider = new QueryProvider(sinon); this._hub = { + serverUri: Uri.parse(remote.normalizedHost), octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockTelemetry(), true)), graphql: {} as any, }; diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index c5f0e44ded..d9be651570 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -69,6 +69,7 @@ describe('GitHub Pull Requests view', function () { // a dummy GitHub/Octokit object. sinon.stub(credentialStore, 'showSignInNotification').callsFake(async () => { const github: GitHub = { + serverUri: vscode.Uri.parse('https://github.com'), octokit: new LoggingOctokit(new Octokit({ request: {}, baseUrl: 'https://github.com',