diff --git a/README.md b/README.md index a30bc22998..7d55caf79f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ There are several settings that can be used to configure the extension. As mentioned above, `githubPullRequests.remotes` is used to specify what remotes the extension should try to fetch pull requests from. +By default, GitHub Enterprise authentication uses the generic VS Code setting `github-enterprise.uri`. If this extension needs to connect to a different GitHub Enterprise host than the rest of VS Code, set `githubPullRequests.customEnterpriseUri` to that server URL. This is intended for split-host setups such as using one enterprise host for Copilot or other GitHub integrations and a different enterprise host for Pull Requests and Issues. + To customize the pull request tree, you can use the `githubPullRequests.queries` setting. This setting is a list of labels and search queries which populate the categories of the tree. By default, these queries are "Waiting For My Review", "Assigned To Me", and "Created By Me". An example of adding a "Mentioned Me" category is to change the setting to the following: ``` diff --git a/package.json b/package.json index d671c7331d..ff86c3ac32 100644 --- a/package.json +++ b/package.json @@ -183,6 +183,11 @@ }, "markdownDescription": "%githubPullRequests.remotes.markdownDescription%" }, + "githubPullRequests.customEnterpriseUri": { + "type": "string", + "default": "", + "markdownDescription": "%githubPullRequests.customEnterpriseUri.markdownDescription%" + }, "githubPullRequests.autoRepositoryDetection": { "markdownDescription": "%githubPullRequests.autoRepositoryDetection.markdownDescription%", "default": "workspace", @@ -1213,6 +1218,16 @@ "title": "%command.pr.signinenterprise.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.signinCustomEnterprise", + "title": "%command.pr.signinCustomEnterprise.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.clearEnterpriseToken", + "title": "%command.pr.clearEnterpriseToken.title%", + "category": "%command.pull.request.category%" + }, { "command": "pr.deleteLocalBranchesNRemotes", "title": "%command.pr.deleteLocalBranchesNRemotes.title%", @@ -1931,7 +1946,12 @@ }, { "view": "github:login", - "when": "(ReposManagerStateContext == NeedsAuthentication) && ((!github:hasGitHubRemotes && gitOpenRepositoryCount) || config.github-enterprise.uri)", + "when": "(ReposManagerStateContext == NeedsAuthentication) && config.githubPullRequests.customEnterpriseUri", + "contents": "%welcome.github.loginWithCustomEnterprise.contents%" + }, + { + "view": "github:login", + "when": "(ReposManagerStateContext == NeedsAuthentication) && !config.githubPullRequests.customEnterpriseUri && (((!github:hasGitHubRemotes && gitOpenRepositoryCount) || config.github-enterprise.uri))", "contents": "%welcome.github.loginWithEnterprise.contents%" }, { @@ -2322,6 +2342,14 @@ "command": "pr.signinenterprise", "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" }, + { + "command": "pr.signinCustomEnterprise", + "when": "config.githubPullRequests.customEnterpriseUri" + }, + { + "command": "pr.clearEnterpriseToken", + "when": "config.githubPullRequests.customEnterpriseUri" + }, { "command": "pr.signinAndRefreshList", "when": "false" diff --git a/package.nls.json b/package.nls.json index 734cac7d5c..3b576d148f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,7 @@ "githubPullRequests.codingAgent.autoCommitAndPush.description": "Allow automatic git operations (commit, push) to be performed when starting a coding agent session.", "githubPullRequests.codingAgent.promptForConfirmation.description": "Prompt for confirmation before initiating a coding agent session from the UI integration.", "githubPullRequests.remotes.markdownDescription": "List of remotes, by name, to fetch pull requests from.", + "githubPullRequests.customEnterpriseUri.markdownDescription": "Custom GitHub Enterprise server URL for this extension only. By default, GitHub Pull Requests and Issues uses `github-enterprise.uri`. Set this only when this extension should authenticate against a different enterprise server than the generic GitHub Enterprise setting used elsewhere in VS Code.", "githubPullRequests.autoRepositoryDetection.markdownDescription": "Controls which repositories are automatically detected and opened by the extension.", "githubPullRequests.autoRepositoryDetection.workspace": "Only detect repositories within the current workspace folders.", "githubPullRequests.autoRepositoryDetection.true": "Detect all repositories found by the Git extension, including those outside workspace folders.", @@ -257,6 +258,8 @@ "command.pr.deleteLocalBranch.title": "Delete Local Branch", "command.pr.signin.title": "Sign in to GitHub", "command.pr.signinenterprise.title": "Sign in to GitHub Enterprise", + "command.pr.signinCustomEnterprise.title": "Sign in to Custom GitHub Enterprise", + "command.pr.clearEnterpriseToken.title": "Clear GitHub Enterprise Token", "command.pr.deleteLocalBranchesNRemotes.title": "Delete local branches and remotes", "command.pr.createComment.title": "Add Review Comment", "command.pr.createSingleComment.title": "Add Comment", @@ -394,6 +397,13 @@ "{Locked='](command:pr.signinenterprise)'}" ] }, + "welcome.github.loginWithCustomEnterprise.contents": { + "message": "[Sign in with Custom GitHub Enterprise](command:pr.signinCustomEnterprise)", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:pr.signinCustomEnterprise)'}" + ] + }, "welcome.pr.github.uninitialized.contents": "Loading...", "welcome.pr.github.noFolder.contents": { "message": "You have not yet opened a folder.\n[Open Folder](command:workbench.action.files.openFolder)", diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 993fc9cda2..d0ea69e9d2 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -17,6 +17,14 @@ export class GitHubManager { private static readonly _neverGitHubServers = new Set().add('bitbucket.org').add('gitlab.com').add('codeberg.org'); 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][]); + public clearServerCache(authority?: string): void { + if (!authority) { + return; + } + + this._knownServers.delete(authority.toLowerCase()); + } + public static isGithubDotCom(host: string): boolean { return this._githubDotComServers.has(host); } diff --git a/src/commands.ts b/src/commands.ts index a52e45473d..5e83f285ac 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -30,7 +30,7 @@ import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; -import { codespacesPrLink, getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; +import { codespacesPrLink, getIssuesUrl, getPullRequestEnterpriseUri, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; import { BaseContext, OverviewContext } from './github/views'; import { IssueChatContextItem } from './lm/issueContextProvider'; import { PRChatContextItem } from './lm/pullRequestContextProvider'; @@ -1132,7 +1132,34 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand('pr.signinenterprise', async () => { - await reposManager.authenticate(true); + const didSignIn = getPullRequestEnterpriseUri() + ? await reposManager.authenticateWithLegacyEnterprise(true) + : await reposManager.authenticate(true, true); + + if (didSignIn) { + vscode.commands.executeCommand('pr.refreshList'); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinCustomEnterprise', async () => { + if (await reposManager.authenticateWithCustomEnterprise(true)) { + vscode.commands.executeCommand('pr.refreshList'); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.clearEnterpriseToken', async () => { + const cleared = await reposManager.credentialStore.clearEnterpriseToken(); + if (!cleared) { + vscode.window.showInformationMessage(vscode.l10n.t('No extension-managed GitHub Enterprise token is currently stored.')); + return; + } + + await reposManager.clearCredentialCache(); + vscode.window.showInformationMessage(vscode.l10n.t('Cleared the GitHub Enterprise token stored for this extension.')); }), ); diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index ba0654cc23..f5f7e17ce4 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -39,6 +39,7 @@ export const SELECT_REMOTE = 'selectRemote'; export const SELECT_WORKTREE = 'selectWorktree'; export const DELETE_BRANCH_AFTER_MERGE = 'deleteBranchAfterMerge'; export const REMOTES = 'remotes'; +export const CUSTOM_ENTERPRISE_URI = 'customEnterpriseUri'; export const PULL_PR_BRANCH_BEFORE_CHECKOUT = 'pullPullRequestBranchBeforeCheckout'; export type PullPRBranchVariants = 'never' | 'pull' | 'pullAndMergeBase' | 'pullAndUpdateBase' | true | false; export const UPSTREAM_REMOTE = 'upstreamRemote'; diff --git a/src/github/copilotApi.ts b/src/github/copilotApi.ts index 7b172e3cae..71eb033798 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'; @@ -116,17 +115,7 @@ export interface SessionInfo { } export async function getCopilotApi(credentialStore: CredentialStore, telemetry: ITelemetry, authProvider?: AuthProvider): Promise { - if (!authProvider) { - if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - authProvider = AuthProvider.githubEnterprise; - } else if (credentialStore.isAuthenticated(AuthProvider.github)) { - authProvider = AuthProvider.github; - } else { - return; - } - } - - const github = credentialStore.getHub(authProvider); + const github = await credentialStore.getCopilotHub(authProvider); if (!github || !github.octokit) { return; } diff --git a/src/github/copilotRemoteAgent.ts b/src/github/copilotRemoteAgent.ts index bc2c7ce9e4..206c007dd3 100644 --- a/src/github/copilotRemoteAgent.ts +++ b/src/github/copilotRemoteAgent.ts @@ -57,6 +57,11 @@ export class CopilotRemoteAgentManager extends Disposable { ) { super(); + this._register(this.credentialStore.onDidChangeSessions(() => { + this._copilotApiPromise = undefined; + this._isAssignable = undefined; + })); + this._register(new CopilotPRWatcher(this.repositoriesManager, this.prsTreeModel)); } private _copilotApiPromise: Promise | undefined; diff --git a/src/github/credentials.ts b/src/github/credentials.ts index d4422d3607..b0bd165d5d 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -11,14 +11,13 @@ 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 { convertRESTUserToAccount, getEnterpriseUri, getLegacyEnterpriseUri, getPullRequestEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; import { AuthProvider } 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 { CUSTOM_ENTERPRISE_URI, GITHUB_ENTERPRISE, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { agent } from '../env/node/net'; @@ -29,6 +28,7 @@ const IGNORE_COMMAND = vscode.l10n.t('Don\'t Show Again'); const PROMPT_FOR_SIGN_IN_SCOPE = vscode.l10n.t('prompt for sign in'); const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login'; +const ENTERPRISE_TOKEN_SECRET_PREFIX = 'githubPullRequest.enterpriseToken'; // If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems. const SCOPES_OLDEST = ['read:user', 'user:email', 'repo']; @@ -55,6 +55,8 @@ export class CredentialStore extends Disposable { private _sessionId: string | undefined; private _githubEnterpriseAPI: GitHub | undefined; private _enterpriseSessionId: string | undefined; + private _legacyGitHubEnterpriseAPI: GitHub | undefined; + private _legacyEnterpriseSessionId: string | undefined; private _isInitialized: boolean = false; private _onDidInitialize: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidInitialize: vscode.Event = this._onDidInitialize.event; @@ -76,16 +78,23 @@ export class CredentialStore extends Disposable { this.setScopesFromState(); this._register(vscode.authentication.onDidChangeSessions((e) => this.handlOnDidChangeSessions(e))); + this._register(vscode.workspace.onDidChangeConfiguration((e) => this.handleConfigurationChange(e))); } private async handlOnDidChangeSessions(e: vscode.AuthenticationSessionsChangeEvent) { + if (e.provider.id === AuthProvider.githubEnterprise) { + this._legacyGitHubEnterpriseAPI = undefined; + this._legacyEnterpriseSessionId = undefined; + } + 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) { return; } if (currentProvider) { const newSession = await this.getSession(currentProvider, { silent: true }, currentProvider === AuthProvider.github ? this._scopes : this._scopesEnterprise, true); - if (newSession.session?.id === this._sessionId) { + const currentSessionId = currentProvider === AuthProvider.github ? this._sessionId : this._enterpriseSessionId; + if (newSession.session?.id === currentSessionId) { return; } if (currentProvider === AuthProvider.github) { @@ -126,11 +135,148 @@ export class CredentialStore extends Disposable { return this._scopes; } + public async clearEnterpriseToken(): Promise { + if (!this.usesExtensionEnterpriseAuth(AuthProvider.githubEnterprise)) { + return false; + } + + return this.clearExtensionEnterpriseToken(true); + } + private async saveScopesInState() { await this.context.globalState.update(LAST_USED_SCOPES_GITHUB_KEY, this._scopes); await this.context.globalState.update(LAST_USED_SCOPES_ENTERPRISE_KEY, this._scopesEnterprise); } + private usesExtensionEnterpriseAuth(authProviderId: AuthProvider): boolean { + return isEnterprise(authProviderId) && !!getPullRequestEnterpriseUri(); + } + + private getEnterpriseTokenKey(): string | undefined { + const enterpriseUri = getPullRequestEnterpriseUri(); + return enterpriseUri ? `${ENTERPRISE_TOKEN_SECRET_PREFIX}:${enterpriseUri.authority.toLowerCase()}` : undefined; + } + + private async getStoredEnterpriseToken(): Promise { + const tokenKey = this.getEnterpriseTokenKey(); + return tokenKey ? this.context.secrets.get(tokenKey) : undefined; + } + + private async storeEnterpriseToken(token: string): Promise { + const tokenKey = this.getEnterpriseTokenKey(); + if (!tokenKey) { + throw new Error('GitHub Enterprise server URL is not configured for this extension.'); + } + + await this.context.secrets.store(tokenKey, token); + } + + private createEnterpriseSession(token: string): vscode.AuthenticationSession { + const authority = getPullRequestEnterpriseUri()?.authority ?? 'github-enterprise'; + return { + id: `${ENTERPRISE_TOKEN_SECRET_PREFIX}:${authority}`, + accessToken: token, + account: { + id: authority, + label: authority, + }, + scopes: SCOPES_WITH_ADDITIONAL, + }; + } + + private getSessionPromptDetail(getAuthSessionOptions: vscode.AuthenticationGetSessionOptions): string | undefined { + const presentation = (typeof getAuthSessionOptions.forceNewSession === 'object') + ? getAuthSessionOptions.forceNewSession + : (typeof getAuthSessionOptions.createIfNone === 'object' ? getAuthSessionOptions.createIfNone : undefined); + + return presentation?.detail; + } + + private async promptForEnterpriseToken(getAuthSessionOptions: vscode.AuthenticationGetSessionOptions): Promise { + const authority = getPullRequestEnterpriseUri()?.authority ?? vscode.l10n.t('GitHub Enterprise'); + const token = await vscode.window.showInputBox({ + title: vscode.l10n.t('GitHub Enterprise Personal Access Token'), + prompt: this.getSessionPromptDetail(getAuthSessionOptions) ?? vscode.l10n.t('Enter a personal access token for {0}.', authority), + password: true, + ignoreFocusOut: true, + validateInput: value => value.trim().length ? undefined : vscode.l10n.t('A personal access token is required.'), + }); + + if (!token?.trim()) { + throw new Error('Cancelled'); + } + + return token.trim(); + } + + private async getExtensionEnterpriseSession(getAuthSessionOptions: vscode.AuthenticationGetSessionOptions): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean, scopes: string[] }> { + const storedToken = getAuthSessionOptions.forceNewSession ? undefined : await this.getStoredEnterpriseToken(); + if (storedToken) { + return { session: this.createEnterpriseSession(storedToken), isNew: false, scopes: SCOPES_WITH_ADDITIONAL }; + } + + if (getAuthSessionOptions.silent || (!getAuthSessionOptions.createIfNone && !getAuthSessionOptions.forceNewSession)) { + return { session: undefined, isNew: false, scopes: SCOPES_WITH_ADDITIONAL }; + } + + const token = await this.promptForEnterpriseToken(getAuthSessionOptions); + await this.storeEnterpriseToken(token); + return { session: this.createEnterpriseSession(token), isNew: true, scopes: SCOPES_WITH_ADDITIONAL }; + } + + private createSessionsChangeEvent(authProviderId: AuthProvider): vscode.AuthenticationSessionsChangeEvent { + return { + provider: { + id: authProviderId, + label: isEnterprise(authProviderId) ? 'GitHub Enterprise' : 'GitHub', + }, + }; + } + + private async clearExtensionEnterpriseToken(emitEvent: boolean): Promise { + const tokenKey = this.getEnterpriseTokenKey(); + if (!tokenKey) { + return false; + } + + const hadToken = !!(await this.context.secrets.get(tokenKey)); + if (!hadToken) { + return false; + } + + await this.context.secrets.delete(tokenKey); + this._githubEnterpriseAPI = undefined; + this._enterpriseSessionId = undefined; + + if (emitEvent) { + this._onDidChangeSessions.fire(this.createSessionsChangeEvent(AuthProvider.githubEnterprise)); + } + + return true; + } + + private async handleConfigurationChange(e: vscode.ConfigurationChangeEvent): Promise { + if (!e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${CUSTOM_ENTERPRISE_URI}`) && !e.affectsConfiguration(`${GITHUB_ENTERPRISE}.${URI}`)) { + return; + } + + this._githubEnterpriseAPI = undefined; + this._enterpriseSessionId = undefined; + this._legacyGitHubEnterpriseAPI = undefined; + this._legacyEnterpriseSessionId = undefined; + + if (!hasEnterpriseUri()) { + this._onDidChangeSessions.fire(this.createSessionsChangeEvent(AuthProvider.githubEnterprise)); + return; + } + + await this.initialize(AuthProvider.githubEnterprise); + if (this.isAuthenticated(AuthProvider.githubEnterprise)) { + this._onDidGetSession.fire(); + } + this._onDidChangeSessions.fire(this.createSessionsChangeEvent(AuthProvider.githubEnterprise)); + } + private async tryInitializeFromEnvironmentToken(authProviderId: AuthProvider): Promise { if (isEnterprise(authProviderId)) { return undefined; @@ -194,7 +340,7 @@ export class CredentialStore extends Disposable { } catch (e) { this._scopes = oldScopes; this._scopesEnterprise = oldEnterpriseScopes; - const userCanceld = (e.message === 'User did not consent to login.'); + const userCanceld = (e.message === 'User did not consent to login.') || (e.message === 'Cancelled'); if (userCanceld) { authResult.canceled = true; } @@ -218,6 +364,12 @@ export class CredentialStore extends Disposable { } catch (e) { if ((e.message === 'Bad credentials') && !getAuthSessionOptions.forceNewSession) { Logger.debug(`Creating hub failed ${e.message}`, CredentialStore.ID); + if (this.usesExtensionEnterpriseAuth(authProviderId)) { + await this.clearExtensionEnterpriseToken(false); + if (!getAuthSessionOptions.createIfNone) { + return authResult; + } + } getAuthSessionOptions.forceNewSession = true; getAuthSessionOptions.silent = false; return this.initialize(authProviderId, getAuthSessionOptions, scopes, requireScopes); @@ -264,8 +416,16 @@ export class CredentialStore extends Disposable { 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); + // Listen for changes to either the extension-scoped or legacy enterprise URI and try again if one becomes available. + const eventDisposable = vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${CUSTOM_ENTERPRISE_URI}`) || e.affectsConfiguration(`${GITHUB_ENTERPRISE}.${URI}`)) { + if (hasEnterpriseUri()) { + initializeEnterprise(); + eventDisposable.dispose(); + } + } + }); + this.context.subscriptions.push(eventDisposable); } const githubOptions = { ...options }; if (enterprise && !enterprise.canceled) { @@ -288,6 +448,8 @@ export class CredentialStore extends Disposable { public async reset() { this._githubAPI = undefined; this._githubEnterpriseAPI = undefined; + this._legacyGitHubEnterpriseAPI = undefined; + this._legacyEnterpriseSessionId = undefined; return this.create(); } @@ -316,6 +478,23 @@ export class CredentialStore extends Disposable { return this._githubEnterpriseAPI; } + public async loginToLegacyEnterprise(forceNewSession: boolean = false): Promise { + return this.getLegacyEnterpriseHub(forceNewSession ? { forceNewSession: true } : { createIfNone: true }); + } + + public async getCopilotHub(authProvider?: AuthProvider): Promise { + if (authProvider === AuthProvider.github) { + return this._githubAPI; + } + + const legacyEnterpriseHub = await this.getLegacyEnterpriseHub({ silent: true }); + if (authProvider === AuthProvider.githubEnterprise) { + return legacyEnterpriseHub; + } + + return legacyEnterpriseHub ?? this._githubAPI; + } + public areScopesOld(authProviderId: AuthProvider): boolean { if (!isEnterprise(authProviderId)) { return !this.allScopesIncluded(this._scopes, SCOPES_OLD); @@ -397,7 +576,7 @@ export class CredentialStore extends Disposable { } } - public async login(authProviderId: AuthProvider): Promise { + public async login(authProviderId: AuthProvider, forceNewSession: boolean = false): Promise { /* __GDPR__ "auth.start" : {} */ @@ -406,7 +585,8 @@ export class CredentialStore extends Disposable { const errorPrefix = vscode.l10n.t('Error signing in to GitHub{0}', getGitHubSuffix(authProviderId)); let retry: boolean = true; let octokit: GitHub | undefined = undefined; - const sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true }; + const sessionOptions: vscode.AuthenticationGetSessionOptions = forceNewSession ? { forceNewSession: true } : { createIfNone: true }; + const wasAuthenticated = this.isAuthenticated(authProviderId); let isCanceled: boolean = false; while (retry) { try { @@ -433,6 +613,10 @@ export class CredentialStore extends Disposable { } if (octokit) { + if (this.usesExtensionEnterpriseAuth(authProviderId) && (!wasAuthenticated || forceNewSession)) { + this._onDidGetSession.fire(); + this._onDidChangeSessions.fire(this.createSessionsChangeEvent(authProviderId)); + } /* __GDPR__ "auth.success" : {} */ @@ -486,6 +670,10 @@ 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[] }> { + if (this.usesExtensionEnterpriseAuth(authProviderId)) { + return this.getExtensionEnterpriseSession(getAuthSessionOptions); + } + const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await this.findExistingScopes(authProviderId); if (existingSession?.session) { return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; @@ -505,12 +693,47 @@ export class CredentialStore extends Disposable { } } - private async createHub(token: string, authProviderId: AuthProvider): Promise { + private async getLegacyEnterpriseHub(getAuthSessionOptions: vscode.AuthenticationGetSessionOptions): Promise { + const legacyEnterpriseUri = getLegacyEnterpriseUri(); + if (!legacyEnterpriseUri) { + return undefined; + } + + if (!getPullRequestEnterpriseUri()) { + return this._githubEnterpriseAPI; + } + + if (!getAuthSessionOptions.forceNewSession && this._legacyGitHubEnterpriseAPI) { + return this._legacyGitHubEnterpriseAPI; + } + + let session = (!getAuthSessionOptions.forceNewSession && !getAuthSessionOptions.createIfNone) + ? (await this.findExistingScopes(AuthProvider.githubEnterprise))?.session + : undefined; + + if (!session) { + session = await vscode.authentication.getSession(AuthProvider.githubEnterprise, SCOPES_OLD, getAuthSessionOptions); + } + + if (!session) { + return undefined; + } + + if (!getAuthSessionOptions.forceNewSession && this._legacyGitHubEnterpriseAPI && this._legacyEnterpriseSessionId === session.id) { + return this._legacyGitHubEnterpriseAPI; + } + + this._legacyEnterpriseSessionId = session.id; + this._legacyGitHubEnterpriseAPI = await this.createHub(session.accessToken, AuthProvider.githubEnterprise, legacyEnterpriseUri); + return this._legacyGitHubEnterpriseAPI; + } + + private async createHub(token: string, authProviderId: AuthProvider, enterpriseServerUriOverride?: vscode.Uri): Promise { let baseUrl = 'https://api.github.com'; let enterpriseServerUri: vscode.Uri | undefined; Logger.appendLine(`Creating hub for ${isEnterprise(authProviderId) ? 'enterprise' : '.com'}`, CredentialStore.ID); if (isEnterprise(authProviderId)) { - enterpriseServerUri = getEnterpriseUri(); + enterpriseServerUri = enterpriseServerUriOverride ?? getEnterpriseUri(); } const isGhe = enterpriseServerUri?.authority.endsWith('ghe.com'); diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 14a6bc6463..23e11577e6 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -20,6 +20,7 @@ import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel' import { convertRESTIssueToRawPullRequest, convertRESTPullRequestToRawPullRequest, + getEnterpriseUri, getOverrideBranch, getPRFetchQuery, getStateFromQuery, @@ -46,14 +47,17 @@ import { CHAT_SETTINGS_NAMESPACE, CHECKOUT_DEFAULT_BRANCH, CHECKOUT_PULL_REQUEST_BASE_BRANCH, + CUSTOM_ENTERPRISE_URI, DISABLE_AI_FEATURES, GIT, + GITHUB_ENTERPRISE, POST_DONE, PR_SETTINGS_NAMESPACE, PULL_BEFORE_CHECKOUT, PULL_BRANCH, REMOTES, UPSTREAM_REMOTE, + URI, } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { EventType } from '../common/timelineEvent'; @@ -226,6 +230,7 @@ export class FolderRepositoryManager extends Disposable { readonly onDidDispose: vscode.Event = this._onDidDispose.event; private _sessionIgnoredRemoteNames: Set = new Set(); + private _enterpriseAuthority: string | undefined; constructor( private readonly _id: number, @@ -240,16 +245,31 @@ export class FolderRepositoryManager extends Disposable { super(); this._githubRepositories = []; this._githubManager = new GitHubManager(); + this._enterpriseAuthority = getEnterpriseUri()?.authority.toLowerCase(); this._register( vscode.workspace.onDidChangeConfiguration(async e => { if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${REMOTES}`)) { await this.updateRepositories(); } + + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${CUSTOM_ENTERPRISE_URI}`) || e.affectsConfiguration(`${GITHUB_ENTERPRISE}.${URI}`)) { + if (this._enterpriseAuthority) { + this._githubManager.clearServerCache(this._enterpriseAuthority); + } + + this._enterpriseAuthority = getEnterpriseUri()?.authority.toLowerCase(); + if (this._enterpriseAuthority) { + this._githubManager.clearServerCache(this._enterpriseAuthority); + } + + await this.updateRepositories(); + } }), ); this._register(_credentialStore.onDidInitialize(() => this.updateRepositories())); + this._register(_credentialStore.onDidChangeSessions(() => this.updateRepositories())); this._register({ dispose: () => disposeAll(this._onDidChangePullRequestsEvents) }); this.cleanStoredRepoState(); diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index beff98508b..bcbabc83f6 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -8,7 +8,7 @@ import { CredentialStore } from './credentials'; import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; import { PullRequestChangeEvent } from './githubRepository'; import { IssueModel } from './issueModel'; -import { findDotComAndEnterpriseRemotes, getEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; +import { findDotComAndEnterpriseRemotes, getEnterpriseUri, getLegacyEnterpriseUri, getPullRequestEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; import { Repository } from '../api/api'; import { AuthProvider } from '../common/authentication'; import { commands, contexts } from '../common/executeCommands'; @@ -257,7 +257,43 @@ export class RepositoriesManager extends Disposable { this.updateState(ReposManagerState.NeedsAuthentication); } - async authenticate(enterprise?: boolean): Promise { + async authenticateWithCustomEnterprise(forceNewSession: boolean = true): Promise { + if (!getPullRequestEnterpriseUri()) { + const openSettings = vscode.l10n.t('Open Settings'); + const selection = await vscode.window.showErrorMessage( + vscode.l10n.t('Set githubPullRequests.customEnterpriseUri before signing in with a custom GitHub Enterprise server.'), + openSettings, + ); + + if (selection === openSettings) { + await vscode.commands.executeCommand('workbench.action.openSettings', 'githubPullRequests.customEnterpriseUri'); + } + + return false; + } + + return !!(await this._credentialStore.login(AuthProvider.githubEnterprise, forceNewSession)); + } + + async authenticateWithLegacyEnterprise(forceNewSession: boolean = true): Promise { + if (!getLegacyEnterpriseUri()) { + const openSettings = vscode.l10n.t('Open Settings'); + const selection = await vscode.window.showErrorMessage( + vscode.l10n.t('Set github-enterprise.uri before signing in to the default GitHub Enterprise server.'), + openSettings, + ); + + if (selection === openSettings) { + await vscode.commands.executeCommand('workbench.action.openSettings', 'github-enterprise.uri'); + } + + return false; + } + + return !!(await this._credentialStore.loginToLegacyEnterprise(forceNewSession)); + } + + async authenticate(enterprise?: boolean, forceNewSession?: boolean): Promise { if (enterprise === false) { return !!this._credentialStore.login(AuthProvider.github); } @@ -270,7 +306,7 @@ export class RepositoriesManager extends Disposable { Logger.appendLine(`Enterprise login selected, but no possible enterprise remotes discovered (${dotComRemotes.length} .com)`, RepositoriesManager.ID); } if (remoteToUse) { - const no = vscode.l10n.t('No, manually set {0}', 'github-enterprise.uri'); + const no = vscode.l10n.t('No, manually set {0}', 'githubPullRequests.customEnterpriseUri'); const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', remoteToUse), { modal: true }, yes, no); if (promptResult === yes) { @@ -312,7 +348,7 @@ export class RepositoriesManager extends Disposable { let githubEnterprise; const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { - githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); + githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise, !!forceNewSession && !!getPullRequestEnterpriseUri()); } let github; if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { diff --git a/src/github/utils.ts b/src/github/utils.ts index 62035f7119..a153af5366 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -52,7 +52,7 @@ import { emojify } from '../common/emoji'; import { GitHubRef } from '../common/githubRef'; import Logger from '../common/logger'; import { Remote } from '../common/remote'; -import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; +import { CUSTOM_ENTERPRISE_URI, GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; import * as Common from '../common/timelineEvent'; import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri'; import { escapeRegExp, gitHubLabelColor, processDiffLinks as processDiffLinksCore, processPermalinks as processPermalinksCore, stringReplaceAsync, uniqBy } from '../common/utils'; @@ -1730,11 +1730,10 @@ export function isInCodespaces(): boolean { } export async function setEnterpriseUri(host: string) { - return vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).update(URI, host, vscode.ConfigurationTarget.Workspace); + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(CUSTOM_ENTERPRISE_URI, host, vscode.ConfigurationTarget.Workspace); } -export function getEnterpriseUri(): vscode.Uri | undefined { - const config: string = vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).get(URI, ''); +function parseEnterpriseUri(config: string): vscode.Uri | undefined { if (config) { let uri = vscode.Uri.parse(config, true); if (uri.scheme === 'http') { @@ -1744,6 +1743,20 @@ export function getEnterpriseUri(): vscode.Uri | undefined { } } +export function getPullRequestEnterpriseUri(): vscode.Uri | undefined { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + return parseEnterpriseUri(config.get(CUSTOM_ENTERPRISE_URI, '')); +} + +export function getLegacyEnterpriseUri(): vscode.Uri | undefined { + const config = vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).get(URI, ''); + return parseEnterpriseUri(config); +} + +export function getEnterpriseUri(): vscode.Uri | undefined { + return getPullRequestEnterpriseUri() ?? getLegacyEnterpriseUri(); +} + export function hasEnterpriseUri(): boolean { return !!getEnterpriseUri(); } diff --git a/src/test/authentication/githubServer.test.ts b/src/test/authentication/githubServer.test.ts new file mode 100644 index 0000000000..c29f61febf --- /dev/null +++ b/src/test/authentication/githubServer.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import * as vscode from 'vscode'; + +import { GitHubManager } from '../../authentication/githubServer'; +import { GitHubServerType } from '../../common/authentication'; +import { CUSTOM_ENTERPRISE_URI, GITHUB_ENTERPRISE, PR_SETTINGS_NAMESPACE, URI } from '../../common/settingKeys'; + +describe('GitHubManager', function () { + const originalGetConfiguration = vscode.workspace.getConfiguration; + + afterEach(function () { + vscode.workspace.getConfiguration = originalGetConfiguration; + }); + + function stubEnterpriseConfiguration(customEnterpriseUri: string, legacyEnterpriseUri: string) { + vscode.workspace.getConfiguration = ((section?: string) => { + if (section === PR_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: string) => key === CUSTOM_ENTERPRISE_URI ? (customEnterpriseUri || defaultValue) : defaultValue, + } as unknown as vscode.WorkspaceConfiguration; + } + + if (section === GITHUB_ENTERPRISE) { + return { + get: (key: string, defaultValue?: string) => key === URI ? (legacyEnterpriseUri || defaultValue) : defaultValue, + } as unknown as vscode.WorkspaceConfiguration; + } + + return originalGetConfiguration(section); + }) as typeof vscode.workspace.getConfiguration; + } + + it('treats the configured enterprise host as enterprise even when the exact authority was previously cached as none', async function () { + stubEnterpriseConfiguration('https://enterprise.example.com/', ''); + const manager = new GitHubManager(); + (manager as any)._knownServers.set('enterprise.example.com', GitHubServerType.None); + + const result = await manager.isGitHub(vscode.Uri.parse('https://enterprise.example.com/example-org/example-repo.git')); + + assert.strictEqual(result, GitHubServerType.Enterprise); + }); +}); \ No newline at end of file diff --git a/src/test/github/credentials.test.ts b/src/test/github/credentials.test.ts new file mode 100644 index 0000000000..b9e626de8d --- /dev/null +++ b/src/test/github/credentials.test.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import { createSandbox, SinonSandbox } from 'sinon'; +import * as vscode from 'vscode'; +import { AuthProvider } from '../../common/authentication'; +import { CUSTOM_ENTERPRISE_URI, GITHUB_ENTERPRISE, PR_SETTINGS_NAMESPACE, URI } from '../../common/settingKeys'; +import { CredentialStore } from '../../github/credentials'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockTelemetry } from '../mocks/mockTelemetry'; + +describe('CredentialStore', function () { + let sinon: SinonSandbox; + let context: MockExtensionContext; + let telemetry: MockTelemetry; + let credentialStore: CredentialStore; + const originalGetConfiguration = vscode.workspace.getConfiguration; + + beforeEach(function () { + sinon = createSandbox(); + context = new MockExtensionContext(); + telemetry = new MockTelemetry(); + credentialStore = new CredentialStore(telemetry, context); + }); + + afterEach(function () { + vscode.workspace.getConfiguration = originalGetConfiguration; + credentialStore.dispose(); + context.dispose(); + sinon.restore(); + }); + + function stubEnterpriseConfiguration(customEnterpriseUri: string, legacyEnterpriseUri: string) { + vscode.workspace.getConfiguration = ((section?: string) => { + if (section === PR_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: string) => key === CUSTOM_ENTERPRISE_URI ? (customEnterpriseUri || defaultValue) : defaultValue, + update: () => Promise.resolve(), + } as unknown as vscode.WorkspaceConfiguration; + } + + if (section === GITHUB_ENTERPRISE) { + return { + get: (key: string, defaultValue?: string) => key === URI ? (legacyEnterpriseUri || defaultValue) : defaultValue, + update: () => Promise.resolve(), + } as unknown as vscode.WorkspaceConfiguration; + } + + return originalGetConfiguration(section); + }) as typeof vscode.workspace.getConfiguration; + } + + function stubCreateHub() { + sinon.stub(credentialStore as unknown as { createHub: () => Promise }, 'createHub').resolves({ + octokit: {}, + graphql: {}, + } as any); + } + + it('uses secret storage instead of the shared auth provider for the extension enterprise setting', async function () { + stubEnterpriseConfiguration('https://pr.example.com', ''); + stubCreateHub(); + const getSessionStub = sinon.stub(vscode.authentication, 'getSession').rejects(new Error('should not use shared auth')); + const inputStub = sinon.stub(vscode.window, 'showInputBox').resolves('secret-token'); + + await credentialStore.login(AuthProvider.githubEnterprise); + + assert.strictEqual(inputStub.calledOnce, true); + assert.strictEqual(getSessionStub.called, false); + assert.strictEqual(credentialStore.isAuthenticated(AuthProvider.githubEnterprise), true); + assert.deepStrictEqual(await context.secrets.keys(), ['githubPullRequest.enterpriseToken:pr.example.com']); + }); + + it('prefers the custom extension enterprise setting over the legacy provider when both are present', async function () { + stubEnterpriseConfiguration('https://pr.example.com', 'https://legacy.example.com'); + stubCreateHub(); + const getSessionStub = sinon.stub(vscode.authentication, 'getSession').rejects(new Error('should not use shared auth')); + const inputStub = sinon.stub(vscode.window, 'showInputBox').resolves('secret-token'); + + await credentialStore.login(AuthProvider.githubEnterprise); + + assert.strictEqual(inputStub.calledOnce, true); + assert.strictEqual(getSessionStub.called, false); + assert.deepStrictEqual(await context.secrets.keys(), ['githubPullRequest.enterpriseToken:pr.example.com']); + }); + + it('clears an invalid stored enterprise token and prompts again', async function () { + stubEnterpriseConfiguration('https://pr.example.com', ''); + const createHubStub = sinon.stub(credentialStore as unknown as { createHub: () => Promise }, 'createHub'); + createHubStub.onFirstCall().rejects(new Error('Bad credentials')); + createHubStub.onSecondCall().resolves({ + octokit: {}, + graphql: {}, + } as any); + const getSessionStub = sinon.stub(vscode.authentication, 'getSession').rejects(new Error('should not use shared auth')); + const inputStub = sinon.stub(vscode.window, 'showInputBox'); + inputStub.onFirstCall().resolves('stale-token'); + inputStub.onSecondCall().resolves('fresh-token'); + + await credentialStore.login(AuthProvider.githubEnterprise); + + assert.strictEqual(getSessionStub.called, false); + assert.strictEqual(inputStub.calledTwice, true); + assert.strictEqual(await context.secrets.get('githubPullRequest.enterpriseToken:pr.example.com'), 'fresh-token'); + assert.strictEqual(credentialStore.isAuthenticated(AuthProvider.githubEnterprise), true); + }); + + it('clears the stored extension enterprise token', async function () { + stubEnterpriseConfiguration('https://pr.example.com', ''); + stubCreateHub(); + sinon.stub(vscode.window, 'showInputBox').resolves('secret-token'); + + await credentialStore.login(AuthProvider.githubEnterprise); + const cleared = await credentialStore.clearEnterpriseToken(); + + assert.strictEqual(cleared, true); + assert.deepStrictEqual(await context.secrets.keys(), []); + assert.strictEqual(credentialStore.isAuthenticated(AuthProvider.githubEnterprise), false); + }); + + it('falls back to the shared enterprise auth provider when only the legacy setting is configured', async function () { + stubEnterpriseConfiguration('', 'https://legacy.example.com'); + stubCreateHub(); + const session = { + id: 'legacy-session', + accessToken: 'legacy-token', + account: { id: 'legacy', label: 'legacy' }, + scopes: ['repo'], + } as vscode.AuthenticationSession; + const getSessionStub = sinon.stub(vscode.authentication, 'getSession').resolves(session); + const inputStub = sinon.stub(vscode.window, 'showInputBox').resolves(undefined); + + await credentialStore.login(AuthProvider.githubEnterprise); + + assert.strictEqual(getSessionStub.called, true); + assert.strictEqual(inputStub.called, false); + }); + + it('uses the legacy enterprise auth session for copilot when a custom enterprise URI is configured', async function () { + stubEnterpriseConfiguration('https://pr.example.com', 'https://legacy.example.com'); + const createHubStub = sinon.stub(credentialStore as unknown as { createHub: () => Promise }, 'createHub'); + createHubStub.onFirstCall().resolves({ octokit: {}, graphql: {} } as any); + createHubStub.onSecondCall().resolves({ octokit: { legacy: true }, graphql: {} } as any); + sinon.stub(vscode.window, 'showInputBox').resolves('secret-token'); + const getSessionStub = sinon.stub(vscode.authentication, 'getSession'); + getSessionStub.withArgs(AuthProvider.githubEnterprise, ['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org'], { silent: true }).resolves(undefined as any); + getSessionStub.withArgs(AuthProvider.githubEnterprise, ['read:user', 'user:email', 'repo', 'workflow'], { silent: true }).resolves({ + id: 'legacy-session', + accessToken: 'legacy-token', + account: { id: 'legacy', label: 'legacy' }, + scopes: ['repo', 'workflow'], + } as vscode.AuthenticationSession); + + await credentialStore.login(AuthProvider.githubEnterprise); + const copilotHub = await credentialStore.getCopilotHub(AuthProvider.githubEnterprise); + + assert.strictEqual(credentialStore.isAuthenticated(AuthProvider.githubEnterprise), true); + assert.deepStrictEqual(copilotHub, { octokit: { legacy: true }, graphql: {} } as any); + assert.strictEqual(createHubStub.callCount, 2); + assert.strictEqual((createHubStub.getCall(1).args as unknown[])[0], 'legacy-token'); + assert.strictEqual(((createHubStub.getCall(1).args as unknown[])[2] as vscode.Uri).authority, 'legacy.example.com'); + }); +}); \ No newline at end of file diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 4a43d8ad15..4a9db08514 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -20,7 +20,7 @@ import { GitApiImpl } from '../../api/api1'; import { CredentialStore } from '../../github/credentials'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { Uri } 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'; @@ -68,6 +68,23 @@ describe('PullRequestManager', function () { assert.deepStrictEqual(manager.activePullRequest, pr); }); }); + + describe('authentication updates', function () { + it('refreshes repositories when authentication sessions change after initialization', async function () { + const updateRepositoriesStub = sinon.stub(manager, 'updateRepositories').resolves(true); + + (manager.credentialStore as any)._onDidChangeSessions.fire({ + provider: { + id: AuthProvider.githubEnterprise, + label: 'GitHub Enterprise', + }, + }); + + await Promise.resolve(); + + assert.strictEqual(updateRepositoriesStub.calledOnce, true); + }); + }); }); describe('titleAndBodyFrom', function () { diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index 6d41977abf..9fa82223b9 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -14,7 +14,7 @@ import { GitHubRepository } from '../../github/githubRepository'; import { Uri } from 'vscode'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { GitHubManager } from '../../authentication/githubServer'; -import { GitHubServerType } from '../../common/authentication'; +import { AuthProvider, GitHubServerType } from '../../common/authentication'; describe('GitHubRepository', function () { let sinon: SinonSandbox; @@ -52,4 +52,36 @@ describe('GitHubRepository', function () { // assert(! dotcomRepository.isGitHubDotCom); }); }); + + describe('query reauthentication', function () { + it('recreates credentials and retries when a query returns 401 Unauthorized', async function () { + const url = 'https://github.enterprise.horse/some/repo'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.Enterprise); + const rootUri = Uri.file('C:\\users\\test\\repo'); + const repository = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry, true); + const queryStub = sinon.stub(); + queryStub.onFirstCall().rejects(new Error('401 Unauthorized')); + queryStub.onSecondCall().resolves({ data: { ok: true } }); + sinon.stub(credentialStore, 'isAuthenticated').callsFake((authProviderId: AuthProvider) => authProviderId === AuthProvider.githubEnterprise); + const recreateStub = sinon.stub(credentialStore, 'recreate').resolves({ canceled: false }); + + (repository as any)._hub = { + graphql: { + query: queryStub, + }, + }; + + const result = await repository.query({ + query: { + definitions: [{ name: { value: 'RuntimeExpiryTest' } }], + } as any, + variables: {}, + } as any); + + assert.deepStrictEqual(result, { data: { ok: true } }); + assert.strictEqual(queryStub.calledTwice, true); + assert.strictEqual(recreateStub.calledOnce, true); + assert.strictEqual(recreateStub.firstCall.args[0], 'Your authentication session has lost authorization. You need to sign in again to regain authorization.'); + }); + }); }); diff --git a/src/test/github/repositoriesManager.test.ts b/src/test/github/repositoriesManager.test.ts index e79941605f..5d02b27961 100644 --- a/src/test/github/repositoriesManager.test.ts +++ b/src/test/github/repositoriesManager.test.ts @@ -11,6 +11,8 @@ import { RepositoriesManager } from '../../github/repositoriesManager'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { GitApiImpl } from '../../api/api1'; import { CredentialStore } from '../../github/credentials'; +import { AuthProvider } from '../../common/authentication'; +import { CUSTOM_ENTERPRISE_URI, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; @@ -27,6 +29,7 @@ describe('RepositoriesManager', function () { let reposManager: RepositoriesManager; let createPrHelper: CreatePullRequestHelper; let mockThemeWatcher: MockThemeWatcher; + const originalGetConfiguration = vscode.workspace.getConfiguration; beforeEach(function () { sinon = createSandbox(); @@ -41,9 +44,50 @@ describe('RepositoriesManager', function () { afterEach(function () { context.dispose(); + vscode.workspace.getConfiguration = originalGetConfiguration; sinon.restore(); }); + describe('custom enterprise authentication', function () { + it('uses the configured custom enterprise URI without reopening the setup prompt', async function () { + vscode.workspace.getConfiguration = ((section?: string) => { + if (section === PR_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: string) => key === CUSTOM_ENTERPRISE_URI ? 'https://enterprise.example.com/' : defaultValue, + } as unknown as vscode.WorkspaceConfiguration; + } + + return originalGetConfiguration(section); + }) as typeof vscode.workspace.getConfiguration; + + const loginStub = sinon.stub(credentialStore, 'login').resolves({} as any); + + const result = await reposManager.authenticateWithCustomEnterprise(); + + assert.strictEqual(result, true); + assert.strictEqual(loginStub.calledOnceWithExactly(AuthProvider.githubEnterprise, true), true); + }); + + it('uses the legacy enterprise sign-in flow when requested explicitly', async function () { + vscode.workspace.getConfiguration = ((section?: string) => { + if (section === 'github-enterprise') { + return { + get: (key: string, defaultValue?: string) => key === 'uri' ? 'https://legacy.example.com/' : defaultValue, + } as unknown as vscode.WorkspaceConfiguration; + } + + return originalGetConfiguration(section); + }) as typeof vscode.workspace.getConfiguration; + + const loginStub = sinon.stub(credentialStore, 'loginToLegacyEnterprise').resolves({} as any); + + const result = await reposManager.authenticateWithLegacyEnterprise(); + + assert.strictEqual(result, true); + assert.strictEqual(loginStub.calledOnceWithExactly(true), true); + }); + }); + describe('removeRepo', function () { it('removes only the specified repository when it is not at the last position', function () { const repo1 = new MockRepository(); diff --git a/src/test/github/utils.test.ts b/src/test/github/utils.test.ts index 94683fcf85..0c40428183 100644 --- a/src/test/github/utils.test.ts +++ b/src/test/github/utils.test.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { default as assert } from 'assert'; -import { getPRFetchQuery, sanitizeIssueTitle } from '../../github/utils'; +import * as vscode from 'vscode'; +import { CUSTOM_ENTERPRISE_URI, GITHUB_ENTERPRISE, PR_SETTINGS_NAMESPACE, URI } from '../../common/settingKeys'; +import { getEnterpriseUri, getPRFetchQuery, getPullRequestEnterpriseUri, sanitizeIssueTitle, setEnterpriseUri } from '../../github/utils'; describe('utils', () => { @@ -41,4 +43,68 @@ describe('utils', () => { }); }); }); -}); \ No newline at end of file + + describe('enterprise uri settings', () => { + const originalGetConfiguration = vscode.workspace.getConfiguration; + + afterEach(() => { + vscode.workspace.getConfiguration = originalGetConfiguration; + }); + + function stubEnterpriseConfiguration(customEnterpriseUri: string, legacyEnterpriseUri: string, update?: (section: string, key: string, value: unknown, target: vscode.ConfigurationTarget | boolean | undefined) => Thenable) { + vscode.workspace.getConfiguration = ((section?: string) => { + if (section === PR_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: string) => key === CUSTOM_ENTERPRISE_URI ? (customEnterpriseUri || defaultValue) : defaultValue, + update: (key: string, value: unknown, target?: vscode.ConfigurationTarget | boolean) => update ? update(section, key, value, target) : Promise.resolve(), + } as vscode.WorkspaceConfiguration; + } + + if (section === GITHUB_ENTERPRISE) { + return { + get: (key: string, defaultValue?: string) => key === URI ? (legacyEnterpriseUri || defaultValue) : defaultValue, + update: (key: string, value: unknown, target?: vscode.ConfigurationTarget | boolean) => update ? update(section, key, value, target) : Promise.resolve(), + } as vscode.WorkspaceConfiguration; + } + + return originalGetConfiguration(section); + }) as typeof vscode.workspace.getConfiguration; + } + + it('prefers githubPullRequests.customEnterpriseUri over the generic setting', () => { + stubEnterpriseConfiguration('https://custom.example.com', 'https://legacy.example.com'); + + assert.strictEqual(getPullRequestEnterpriseUri()?.authority, 'custom.example.com'); + assert.strictEqual(getEnterpriseUri()?.authority, 'custom.example.com'); + }); + + it('falls back to github-enterprise.uri when extension-specific settings are unset', () => { + stubEnterpriseConfiguration('', 'https://legacy.example.com'); + + assert.strictEqual(getPullRequestEnterpriseUri(), undefined); + assert.strictEqual(getEnterpriseUri()?.authority, 'legacy.example.com'); + }); + + it('normalizes http enterprise urls to https', () => { + stubEnterpriseConfiguration('http://pr.example.com', ''); + + assert.strictEqual(getEnterpriseUri()?.toString(), 'https://pr.example.com/'); + }); + + it('writes enterprise setup to the extension setting', async () => { + let capturedUpdate: { section: string; key: string; value: unknown; target: vscode.ConfigurationTarget | boolean | undefined } | undefined; + stubEnterpriseConfiguration('', '', async (section, key, value, target) => { + capturedUpdate = { section, key, value, target }; + }); + + await setEnterpriseUri('https://pr.example.com'); + + assert.deepStrictEqual(capturedUpdate, { + section: PR_SETTINGS_NAMESPACE, + key: CUSTOM_ENTERPRISE_URI, + value: 'https://pr.example.com', + target: vscode.ConfigurationTarget.Workspace, + }); + }); + }); +}); diff --git a/src/test/mocks/mockExtensionContext.ts b/src/test/mocks/mockExtensionContext.ts index b15c19907e..f56c3cc040 100644 --- a/src/test/mocks/mockExtensionContext.ts +++ b/src/test/mocks/mockExtensionContext.ts @@ -11,25 +11,33 @@ import { InMemoryMemento } from './inMemoryMemento'; export class MockExtensionContext implements ExtensionContext { extensionPath: string; + private readonly _secretStorage = new Map(); + private readonly _secretStorageChanged = new EventEmitter(); workspaceState = new InMemoryMemento(); globalState = new InMemoryMemento(); secrets = new (class implements SecretStorage { + constructor(private readonly storage: Map, private readonly eventEmitter: EventEmitter) { } + get(key: string): Thenable { - throw new Error('Method not implemented.'); + return Promise.resolve(this.storage.get(key)); } store(key: string, value: string): Thenable { - throw new Error('Method not implemented.'); + this.storage.set(key, value); + this.eventEmitter.fire({ key }); + return Promise.resolve(); } keys(): Thenable { - throw new Error('Method not implemented.'); + return Promise.resolve([...this.storage.keys()]); } delete(key: string): Thenable { - throw new Error('Method not implemented.'); + this.storage.delete(key); + this.eventEmitter.fire({ key }); + return Promise.resolve(); } - onDidChange!: Event; - })(); + onDidChange: Event; + })(this._secretStorage, this._secretStorageChanged); subscriptions: { dispose(): any }[] = []; storagePath: string; @@ -65,6 +73,7 @@ export class MockExtensionContext implements ExtensionContext { this.globalStorageUri = Uri.file(this.globalStoragePath); this.logPath = temp.mkdirSync('log-path'); this.logUri = Uri.file(this.logPath); + this.secrets.onDidChange = this._secretStorageChanged.event; } asAbsolutePath(relativePath: string): string { diff --git a/src/test/view/categoryNode.test.ts b/src/test/view/categoryNode.test.ts new file mode 100644 index 0000000000..d0f6eed32d --- /dev/null +++ b/src/test/view/categoryNode.test.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import * as vscode from 'vscode'; +import { createSandbox, SinonSandbox } from 'sinon'; +import { PRType } from '../../github/interface'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from '../../view/treeNodes/categoryNode'; + +describe('CategoryTreeNode', function () { + let sinon: SinonSandbox; + + beforeEach(function () { + sinon = createSandbox(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('uses the enterprise sign-in command for the enterprise login action', function () { + const node = new PRCategoryActionNode({} as any, PRCategoryActionType.LoginEnterprise); + + assert.strictEqual(node.command?.command, 'pr.signinenterprise'); + }); + + it('uses the custom enterprise sign-in command when a custom enterprise URI is configured', function () { + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = ((section?: string) => { + if (section === 'githubPullRequests') { + return { + get: (key: string, defaultValue?: string) => key === 'customEnterpriseUri' ? 'https://pr.example.com/' : defaultValue, + } as unknown as vscode.WorkspaceConfiguration; + } + + return originalGetConfiguration(section); + }) as typeof vscode.workspace.getConfiguration; + + try { + const node = new PRCategoryActionNode({} as any, PRCategoryActionType.LoginEnterprise); + + assert.strictEqual(node.command?.command, 'pr.signinCustomEnterprise'); + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + it('offers Login again and recreates credentials when fetching pull requests fails with bad credentials', async function () { + const recreateStub = sinon.stub().resolves({ canceled: false }); + const folderRepoManager = { + credentialStore: { + recreate: recreateStub, + }, + } as any; + const prsTreeModel = { + hasLoaded: true, + getPullRequestsForQuery: sinon.stub().rejects(new Error('Bad credentials')), + } as any; + const parent = { + children: undefined, + refresh: sinon.stub(), + reveal: sinon.stub().resolves(), + view: {} as vscode.TreeView, + } as any; + sinon.stub(vscode.window as any, 'showErrorMessage').callsFake(async (...args: any[]) => args[1]); + + const node = new CategoryTreeNode( + parent, + folderRepoManager, + new MockTelemetry(), + PRType.Query, + {} as any, + prsTreeModel, + 'Assigned To Me', + 'is:open assignee:${user}', + ); + + await node.getChildren(); + await Promise.resolve(); + + assert.strictEqual(recreateStub.calledOnce, true); + assert.strictEqual(recreateStub.firstCall.args[0], 'Your login session is no longer valid.'); + }); +}); \ No newline at end of file diff --git a/src/view/treeNodes/categoryNode.ts b/src/view/treeNodes/categoryNode.ts index b7f0d2baa2..25d3acd619 100644 --- a/src/view/treeNodes/categoryNode.ts +++ b/src/view/treeNodes/categoryNode.ts @@ -14,7 +14,7 @@ import { isCopilotQuery } from '../../github/copilotPrWatcher'; import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; import { PRType } from '../../github/interface'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { extractRepoFromQuery } from '../../github/utils'; +import { extractRepoFromQuery, getPullRequestEnterpriseUri } from '../../github/utils'; import { PrsTreeModel } from '../prsTreeModel'; import { PRNode } from './pullRequestNode'; import { TreeNode, TreeNodeParent } from './treeNode'; @@ -71,10 +71,12 @@ export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { }; break; case PRCategoryActionType.LoginEnterprise: - this.label = vscode.l10n.t('Sign in with GitHub Enterprise...'); + this.label = getPullRequestEnterpriseUri() + ? vscode.l10n.t('Sign in with Custom GitHub Enterprise...') + : vscode.l10n.t('Sign in with GitHub Enterprise...'); this.command = { title: 'Sign in', - command: 'pr.signinAndRefreshList', + command: getPullRequestEnterpriseUri() ? 'pr.signinCustomEnterprise' : 'pr.signinenterprise', arguments: [], }; break;