microsoft/vscode · #334594

chat: fix GitHub context repository selection

meganrogge · merged Sep 4, 20267 files · 308 + / 94
extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts29 + / 3
@@ -14,7 +14,7 @@ import { IVSCodeExtensionContext } from '../../../platform/extContext/common/ext import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { FileType } from '../../../platform/filesystem/common/fileTypes'; import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';-import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService';+import { getGithubRepoIdFromFetchUrl, GithubRepoId, IGitService, toGithubNwo } from '../../../platform/git/common/gitService'; import { derivePullRequestState, PullRequestSearchItem } from '../../../platform/github/common/githubAPI'; import { CCAEnabledResult, IGithubRepositoryService, IOctoKitService } from '../../../platform/github/common/githubService'; import { getModelCapabilitiesDescription, normalizeTokenPrices } from '../../conversation/common/languageModelAccess';@@ -247,6 +247,30 @@ export function parseGitHubContextUrl(value: string, kind: 'issue' | 'pullReques 	}; } +export async function resolveGitHubContextRepository(gitService: IGitService, repository: string | vscode.Uri | undefined): Promise<string | undefined> {+	if (!repository || typeof repository === 'string') {+		return repository;+	}++	const repositoryInfo = await gitService.getRepositoryFetchUrls(repository);+	for (const remoteUrl of repositoryInfo?.remoteFetchUrls ?? []) {+		const repositoryId = remoteUrl && getGithubRepoIdFromFetchUrl(remoteUrl);+		if (repositoryId) {+			return toGithubNwo(repositoryId);+		}+	}+	return undefined;+}++export async function resolveOrPickGitHubContextRepository(+	gitService: IGitService,+	repository: string | vscode.Uri | undefined,+	pickRepository: () => Promise<string | undefined>,+): Promise<string | undefined> {+	const repositoryId = await resolveGitHubContextRepository(gitService, repository);+	return repository && !repositoryId ? pickRepository() : repositoryId;+}+ /** Context key gating the chat-input "Create pull request" toolbar action: true while the viewed cloud task is settled and has no PR yet. */ const CAN_CREATE_PULL_REQUEST_CONTEXT_KEY = 'github.copilot.chat.cloudTaskCanCreatePullRequest'; /** Context key gating the chat-input "Open pull request" toolbar action: true once the viewed cloud task has a pull request. */@@ -767,8 +791,10 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C 				})); 			}); 		};-		this._register(vscode.commands.registerCommand(OPEN_ISSUE_COMMAND_ID, (repoId?: string) => openGitHubContext('issue', repoId)));-		this._register(vscode.commands.registerCommand(OPEN_PULL_REQUEST_COMMAND_ID, (repoId?: string) => openGitHubContext('pullRequest', repoId)));+		this._register(vscode.commands.registerCommand(OPEN_ISSUE_COMMAND_ID, async (repository?: string | vscode.Uri) =>+			openGitHubContext('issue', await resolveOrPickGitHubContextRepository(this._gitService, repository, openRepositoryCommand))));+		this._register(vscode.commands.registerCommand(OPEN_PULL_REQUEST_COMMAND_ID, async (repository?: string | vscode.Uri) =>+			openGitHubContext('pullRequest', await resolveOrPickGitHubContextRepository(this._gitService, repository, openRepositoryCommand))));  		this._register(vscode.commands.registerCommand(CLEAR_CACHES_COMMAND_ID, () => { 			this.logService.debug('copilotCloudSessionsProvider#clearCaches: clearing all cloud agent caches');
extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts32 + / 1
@@ -12,7 +12,7 @@ import { mock } from '../../../../util/common/test/simpleMock'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes'; import { ChatSessionContentBuilder, extractTaskErrorDetail, formatTaskStoppedMessage } from '../copilotCloudSessionContentBuilder';-import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, parseGitHubContextUrl, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider';+import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, parseGitHubContextUrl, resolveGitHubContextRepository, resolveOrPickGitHubContextRepository, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend'; import { isActiveTaskState, isFailedTaskState } from '../../vscode/copilotCodingAgentUtils'; import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry';@@ -82,6 +82,37 @@ describe('copilotCloudSessionsProvider helpers', () => { 		}); 	}); +	it('resolves a GitHub context repository from the selected workspace folder', async () => {+		const gitService = new TestGitService();+		gitService.getRepositoryFetchUrls = vi.fn(async () => ({+			rootUri: vscode.Uri.file('/workspace/docs'),+			remoteFetchUrls: ['https://github.com/microsoft/vscode-docs.git'],+		}));++		expect({+			folder: await resolveGitHubContextRepository(gitService, vscode.Uri.file('/workspace/docs')),+			repository: await resolveGitHubContextRepository(gitService, 'microsoft/vscode'),+		}).toEqual({+			folder: 'microsoft/vscode-docs',+			repository: 'microsoft/vscode',+		});+	});++	it('offers repository selection only when a selected folder cannot be resolved', async () => {+		const gitService = new TestGitService();+		gitService.getRepositoryFetchUrls = vi.fn(async () => undefined);+		const pickRepository = vi.fn(async () => 'microsoft/vscode');++		expect({+			selectedFolder: await resolveOrPickGitHubContextRepository(gitService, vscode.Uri.file('/workspace/vscode'), pickRepository),+			noFolder: await resolveOrPickGitHubContextRepository(gitService, undefined, pickRepository),+		}).toEqual({+			selectedFolder: 'microsoft/vscode',+			noFolder: undefined,+		});+		expect(pickRepository).toHaveBeenCalledTimes(1);+	});+ 	it('coerces object-shaped initialSessionOptions into option entries', () => { 		const logService = new RecordingLogService(); 		const sessionResource = vscode.Uri.parse('copilot-cloud-agent:/1');
extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts53 + / 4
@@ -21,7 +21,7 @@ import { isEqual } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { ILogService } from '../../log/common/logService'; import { IGitExtensionService } from '../common/gitExtensionService';-import { IGitService, RepoContext } from '../common/gitService';+import { getOrderedRemoteUrlsFromContext, IGitService, RepoContext } from '../common/gitService'; import { parseGitRemotes } from '../common/utils'; import { API, APIState, Branch, Change, CommitOptions, CommitShortStat, DiffChange, Ref, RefQuery, Repository, RepositoryAccessDetails } from '../vscode/git'; @@ -197,6 +197,30 @@ export class GitServiceImpl extends Disposable implements IGitService { 	async getRepositoryFetchUrls(uri: URI): Promise<Pick<RepoContext, 'rootUri' | 'remoteFetchUrls'> | undefined> { 		this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] URI: ${uri.toString()}`); +		if (uri.scheme === 'file') {+			try {+				const uriStat = await vscode.workspace.fs.stat(uri);+				if (uriStat.type === vscode.FileType.Directory) {+					const config = await this.readLocalGitConfig(uri);+					const parsedRemotes = parseGitRemotes(config);+					const origin = parsedRemotes.find(remote => remote.name === 'origin');+					const orderedRemotes = origin+						? [origin, ...parsedRemotes.filter(remote => remote !== origin)]+						: parsedRemotes;+					const remotes = {+						rootUri: uri,+						remoteFetchUrls: orderedRemotes.map(remote => remote.fetchUrl),+					};+					if (remotes.remoteFetchUrls.length > 0) {+						this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Remotes (direct .git/config): ${JSON.stringify(remotes)}`);+						return remotes;+					}+				}+			} catch (error) {+				this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Could not read remotes directly from .git/config: ${error.message}`);+			}+		}+ 		// Answering before discovery settles reports the file as belonging to no repository, which 		// content exclusion reads as "no repository rules apply to this file". 		await this.waitForInitialDiscovery();@@ -209,11 +233,10 @@ export class GitServiceImpl extends Disposable implements IGitService { 		// Query opened repositories 		const repository = gitAPI.getRepository(uri); 		if (repository) {-			await this.waitForRepositoryState(repository);-+			const repositoryContext = GitServiceImpl.repoToRepoContext(repository); 			const remotes = { 				rootUri: repository.rootUri,-				remoteFetchUrls: repository.state.remotes.map(r => r.fetchUrl),+				remoteFetchUrls: repositoryContext ? Array.from(getOrderedRemoteUrlsFromContext(repositoryContext)) : [], 			};  			this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Remotes (open repository): ${JSON.stringify(remotes)}`);@@ -254,6 +277,32 @@ export class GitServiceImpl extends Disposable implements IGitService { 		} 	} +	private async readLocalGitConfig(rootUri: URI): Promise<string> {+		const dotGitUri = URI.file(path.join(rootUri.fsPath, '.git'));+		const dotGitStat = await vscode.workspace.fs.stat(dotGitUri);+		let gitDirectory = dotGitUri.fsPath;++		if (dotGitStat.type === vscode.FileType.File) {+			const dotGit = (await vscode.workspace.fs.readFile(dotGitUri)).toString();+			const gitDirectoryMatch = /^gitdir:\s*(?<path>.+)\s*$/m.exec(dotGit);+			if (!gitDirectoryMatch?.groups?.path) {+				throw new Error(`Invalid Git directory pointer: ${dotGitUri.fsPath}`);+			}+			gitDirectory = path.resolve(rootUri.fsPath, gitDirectoryMatch.groups.path);++			try {+				const commonDirectory = (await vscode.workspace.fs.readFile(URI.file(path.join(gitDirectory, 'commondir')))).toString().trim();+				if (commonDirectory) {+					gitDirectory = path.resolve(gitDirectory, commonDirectory);+				}+			} catch (error) {+				this.logService.trace(`[GitServiceImpl][readLocalGitConfig] No common Git directory for ${gitDirectory}: ${error.message}`);+			}+		}++		return (await vscode.workspace.fs.readFile(URI.file(path.join(gitDirectory, 'config')))).toString();+	}+ 	async add(uri: URI, paths: string[]): Promise<void> { 		const gitAPI = this.gitExtensionService.getExtensionApi(); 		const repository = gitAPI?.getRepository(uri);
src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts10 + / 18
@@ -26,7 +26,7 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js';-import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js';+import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js';@@ -900,23 +900,13 @@ function githubRemoteRepoLabel(uri: URI): string | undefined { 	return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined; } -async function resolveGitHubRepositoryId(folder: ISessionFolder, gitService: Pick<IGitService, 'openRepository'>): Promise<string | undefined> {+function resolveGitHubRepositoryId(folder: ISessionFolder): string | undefined { 	const gitHubInfo = folder.gitRepository?.gitHubInfo.get(); 	if (gitHubInfo) { 		return `${gitHubInfo.owner}/${gitHubInfo.repo}`; 	} -	const remoteRepositoryId = githubRemoteRepoLabel(folder.root);-	if (remoteRepositoryId || folder.root.scheme !== Schemas.file) {-		return remoteRepositoryId;-	}--	const repository = await gitService.openRepository(folder.gitRepository?.uri ?? folder.root);-	if (!repository || !isEqualOrParent(folder.root, repository.rootUri)) {-		return undefined;-	}-	const repositoryInfo = repository && getGitHubRemoteInfo(repository.state.get());-	return repositoryInfo ? `${repositoryInfo.owner}/${repositoryInfo.repo}` : undefined;+	return githubRemoteRepoLabel(folder.root); }  /**@@ -2759,20 +2749,22 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions 	private async _browseForGitHubContext(commandId: string, icon: ThemeIcon, currentWorkspace: ISessionWorkspace | undefined): Promise<ISessionWorkspace | undefined> { 		const repositoryIds = new Set<string>(); 		for (const folder of currentWorkspace?.folders ?? []) {-			const repositoryId = await resolveGitHubRepositoryId(folder, this.gitService);+			const repositoryId = resolveGitHubRepositoryId(folder); 			if (repositoryId) { 				repositoryIds.add(repositoryId); 			} 		} -		const repoId = repositoryIds.size === 1+		const repository = repositoryIds.size === 1 			? repositoryIds.values().next().value-			: await this.commandService.executeCommand<string>(OPEN_REPO_COMMAND);-		if (!repoId) {+			: currentWorkspace?.folders.length === 1 && currentWorkspace.folders[0].root.scheme === Schemas.file+				? currentWorkspace.folders[0].root+				: await this.commandService.executeCommand<string>(OPEN_REPO_COMMAND);+		if (!repository) { 			return undefined; 		} -		const selection = await this.commandService.executeCommand<IGitHubContextSelection>(commandId, repoId);+		const selection = await this.commandService.executeCommand<IGitHubContextSelection>(commandId, repository); 		if (!selection) { 			return undefined; 		}
src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts9 + / 33
@@ -63,7 +63,6 @@ import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest } fr  interface IGitHubContextBrowseHarness { 	readonly commandService: Pick<ICommandService, 'executeCommand'>;-	readonly gitService: Pick<IGitService, 'openRepository'>; }  const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubContext') as (@@ -471,7 +470,6 @@ suite('CopilotChatSessionsProvider', () => { 					} as T; 				} 			}(),-			gitService: upcastPartial<IGitService>({ openRepository: async () => undefined }), 		}; 		const repositoryRoot = URI.from({ 			scheme: GITHUB_REMOTE_FILE_SCHEME,@@ -527,7 +525,6 @@ suite('CopilotChatSessionsProvider', () => { 					} as T; 				} 			}(),-			gitService: upcastPartial<IGitService>({ openRepository: async () => undefined }), 		}; 		const repositoryRoot = (repositoryId: string) => URI.from({ 			scheme: GITHUB_REMOTE_FILE_SCHEME,@@ -572,30 +569,22 @@ suite('CopilotChatSessionsProvider', () => { 		}); 	}); -	test('resolves an initially unknown GitHub remote before browsing context', async () => {+	test('uses the selected folder without waiting for unresolved local GitHub metadata', async () => { 		const calls: { commandId: string; repoId: unknown }[] = [];-		const repositoryState = observableValue('repositoryState', {-			HEAD: undefined,-			remotes: [{ name: 'origin', fetchUrl: 'https://github.com/microsoft/vscode.git', pushUrl: undefined, isReadOnly: false }],-			mergeChanges: [],-			indexChanges: [],-			workingTreeChanges: [],-			untrackedChanges: [],-		}); 		const harness: IGitHubContextBrowseHarness = { 			commandService: new class extends mock<ICommandService>() { 				override async executeCommand<T>(commandId: string, repoId?: unknown): Promise<T | undefined> { 					calls.push({ commandId, repoId });+					if (commandId === 'github.copilot.chat.cloudSessions.openRepository') {+						return 'microsoft/vscode' as T;+					} 					return { 						repoId: 'microsoft/vscode', 						url: 'https://github.com/microsoft/vscode/issues/1', 						label: 'microsoft/vscode#1', 					} as T; 				} 			}(),-			gitService: upcastPartial<IGitService>({-				openRepository: async () => upcastPartial<IGitRepository>({ rootUri: root, state: repositoryState }),-			}), 		}; 		const root = URI.file('/test/vscode'); 		const workspace: ISessionWorkspace = {@@ -620,21 +609,15 @@ suite('CopilotChatSessionsProvider', () => { 			calls, 			issue: { uri: issue?.uri.toString(), label: issue?.label }, 		}, {-			calls: [{ commandId: 'openIssue', repoId: 'microsoft/vscode' }],+			calls: [+				{ commandId: 'openIssue', repoId: root },+			], 			issue: { uri: 'https://github.com/microsoft/vscode/issues/1', label: 'microsoft/vscode#1' }, 		}); 	}); -	test('selects a repository when the selected folder has no matching Git root', async () => {+	test('passes the selected folder through when it has no matching Git root', async () => { 		const calls: { commandId: string; repoId: unknown }[] = [];-		const staleRepositoryState = observableValue('staleRepositoryState', {-			HEAD: undefined,-			remotes: [{ name: 'origin', fetchUrl: 'https://github.com/microsoft/old.git', pushUrl: undefined, isReadOnly: false }],-			mergeChanges: [],-			indexChanges: [],-			workingTreeChanges: [],-			untrackedChanges: [],-		}); 		const harness: IGitHubContextBrowseHarness = { 			commandService: new class extends mock<ICommandService>() { 				override async executeCommand<T>(commandId: string, repoId?: unknown): Promise<T | undefined> {@@ -649,12 +632,6 @@ suite('CopilotChatSessionsProvider', () => { 					} as T; 				} 			}(),-			gitService: upcastPartial<IGitService>({-				openRepository: async () => upcastPartial<IGitRepository>({-					rootUri: URI.file('/test/old-repository'),-					state: staleRepositoryState,-				}),-			}), 		}; 		const root = URI.file('/test/new-folder'); 		const workspace: ISessionWorkspace = {@@ -670,8 +647,7 @@ suite('CopilotChatSessionsProvider', () => { 		await browseForGitHubContext.call(harness, 'openIssue', Codicon.issues, workspace);  		assert.deepStrictEqual(calls, [-			{ commandId: 'github.copilot.chat.cloudSessions.openRepository', repoId: undefined },-			{ commandId: 'openIssue', repoId: 'microsoft/vscode' },+			{ commandId: 'openIssue', repoId: root }, 		]); 	}); 
src/vs/workbench/contrib/chat/browser/actions/chatContext.ts61 + / 18
@@ -41,6 +41,8 @@ import { getChatSessionType } from '../../common/model/chatUri.js'; import { buildHostLocalEventsPath } from '../copilotCliEventsUri.js'; import { IGitService } from '../../../git/common/gitService.js'; import { getGitHubRemoteInfo } from '../../../git/common/utils.js';+import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';+import { isEqual } from '../../../../../base/common/resources.js';  const OPEN_GITHUB_ISSUE_COMMAND = 'github.copilot.chat.cloudSessions.openIssue'; const OPEN_GITHUB_PULL_REQUEST_COMMAND = 'github.copilot.chat.cloudSessions.openPullRequest';@@ -51,8 +53,9 @@ interface IGitHubContextSelection { 	readonly label: string; } -interface IGitHubRepositoryQuickPickItem extends IQuickPickItem {-	readonly repoId: string;+interface IGitHubRepositoryPick extends IQuickPickItem {+	readonly repoId?: string;+	readonly folderUri?: URI; }  /**@@ -122,6 +125,7 @@ export class GitHubContextValuePick implements IChatContextValueItem { 		@IGitService private readonly gitService: IGitService, 		@IQuickInputService private readonly quickInputService: IQuickInputService, 		@ICommandService private readonly commandService: ICommandService,+		@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, 	) { 		if (kind === 'issue') { 			this.label = localize('chatContext.githubIssue', "Issue...");@@ -139,20 +143,21 @@ export class GitHubContextValuePick implements IChatContextValueItem { 	}  	async asAttachment(): Promise<IChatRequestVariableEntry | undefined> {-		const repositories = this.getRepositories();-		let repoId: string | undefined;+		const repositories = await this.getRepositoryPicks();+		let repository: IGitHubRepositoryPick | undefined;  		if (repositories.length === 1) {-			repoId = repositories[0];+			repository = repositories[0]; 		} else if (repositories.length > 1) {-			repoId = await this.pickRepository(repositories);+			repository = await this.pickRepository(repositories); 		} -		if (repositories.length > 1 && !repoId) {+		if (repositories.length > 1 && !repository) { 			return undefined; 		} -		const selection = await this.commandService.executeCommand<IGitHubContextSelection | undefined>(this._commandId, repoId);+		const repositoryArgument = repository?.repoId ?? this.getRepositoryId(repository?.folderUri) ?? repository?.folderUri;+		const selection = await this.commandService.executeCommand<IGitHubContextSelection | undefined>(this._commandId, repositoryArgument); 		if (!selection) { 			return undefined; 		}@@ -169,23 +174,61 @@ export class GitHubContextValuePick implements IChatContextValueItem { 		}; 	} -	protected async pickRepository(repositories: readonly string[]): Promise<string | undefined> {-		const repository = await this.quickInputService.pick(-			repositories.map((repoId): IGitHubRepositoryQuickPickItem => ({ label: repoId, repoId })),-			{ placeHolder: localize('chatContext.githubRepository.placeholder', "Select a repository") }+	protected async pickRepository(repositories: readonly IGitHubRepositoryPick[]): Promise<IGitHubRepositoryPick | undefined> {+		return this.quickInputService.pick<IGitHubRepositoryPick>(+			[...repositories],+			{+				canPickMany: false,+				placeHolder: localize('chatContext.githubRepository.placeholder', "Select a repository"),+			} 		);-		return repository?.repoId; 	} -	private getRepositories(): readonly string[] {-		const repositories = new Set<string>();-		for (const repository of this.gitService.repositories) {+	private async getRepositoryPicks(): Promise<readonly IGitHubRepositoryPick[]> {+		const knownRepositories = Array.from(this.gitService.repositories);+		const workspaceFolders = this.workspaceContextService.getWorkspace().folders;+		if (workspaceFolders.length > 1) {+			return workspaceFolders.map((folder): IGitHubRepositoryPick => {+				const repository = knownRepositories.find(repository =>+					isEqual(this.workspaceContextService.getWorkspaceFolder(repository.rootUri)?.uri, folder.uri)+				);+				const info = repository && getGitHubRemoteInfo(repository.state.get());+				return info ? {+					label: folder.name,+					description: `${info.owner}/${info.repo}`,+					repoId: `${info.owner}/${info.repo}`,+					folderUri: folder.uri,+				} : {+					label: folder.name,+					folderUri: folder.uri,+				};+			});+		}++		const repositoryIds = new Set<string>();+		for (const repository of knownRepositories) {+			if (!repository) {+				continue;+			} 			const info = getGitHubRemoteInfo(repository.state.get()); 			if (info) {-				repositories.add(`${info.owner}/${info.repo}`);+				repositoryIds.add(`${info.owner}/${info.repo}`); 			} 		}-		return Array.from(repositories).sort();+		return Array.from(repositoryIds)+			.sort()+			.map(repoId => ({ label: repoId, repoId }));+	}++	private getRepositoryId(folderUri: URI | undefined): string | undefined {+		if (!folderUri) {+			return undefined;+		}+		const repository = Array.from(this.gitService.repositories).find(repository =>+			isEqual(this.workspaceContextService.getWorkspaceFolder(repository.rootUri)?.uri, folderUri)+		);+		const info = repository && getGitHubRemoteInfo(repository.state.get());+		return info ? `${info.owner}/${info.repo}` : undefined; 	} } 
src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts114 + / 17
@@ -7,10 +7,11 @@ import assert from 'assert'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js';-import { mock } from '../../../../../../base/test/common/mock.js';+import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js';+import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { GitHubContextValuePick, shouldShowOpenEditorsContext } from '../../../browser/actions/chatContext.js'; import { ChatContextPickService } from '../../../browser/attachments/chatContextPickService.js'; import { IChatWidget } from '../../../browser/chat.js';@@ -30,9 +31,9 @@ function widgetWithSession(sessionResource: URI): Pick<IChatWidget, 'viewModel' 	}); } -function repository(remoteUrl: string): IGitRepository {+function repository(remoteUrl: string, rootUri = URI.file(`/workspace/${remoteUrl.length}`)): IGitRepository { 	return new class extends mock<IGitRepository>() {-		override readonly rootUri = URI.file(`/workspace/${remoteUrl.length}`);+		override readonly rootUri = rootUri; 		override readonly state = observableValue('test', { 			remotes: [{ name: 'origin', fetchUrl: remoteUrl, isReadOnly: false }], 			mergeChanges: [],@@ -44,31 +45,55 @@ function repository(remoteUrl: string): IGitRepository { }  class TestGitService extends mock<IGitService>() {+	readonly openRepositoryCalls: URI[] = [];+ 	constructor(override readonly repositories: readonly IGitRepository[]) { 		super(); 	}++	override async openRepository(uri: URI): Promise<IGitRepository | undefined> {+		this.openRepositoryCalls.push(uri);+		return this.repositories.find(repository => repository.rootUri.toString() === uri.toString());+	} }  class TestCommandService extends mock<ICommandService>() { 	result: object | undefined;-	command: { id: string; repoId: string | undefined } | undefined;+	command: { id: string; repository: string | URI | undefined } | undefined; -	override async executeCommand<T>(id: string, repoId?: string): Promise<T> {-		this.command = { id, repoId };+	override async executeCommand<T>(id: string, repository?: string | URI): Promise<T> {+		this.command = { id, repository }; 		return this.result as T; 	} }  class TestGitHubContextValuePick extends GitHubContextValuePick {-	repositoryPicks: readonly string[] | undefined;+	repositoryPicks: readonly { readonly label: string; readonly description?: string; readonly repoId?: string; readonly folderUri?: URI }[] | undefined; 	selectedRepository: string | undefined; -	protected override async pickRepository(repositories: readonly string[]): Promise<string | undefined> {+	protected override async pickRepository(repositories: readonly { readonly label: string; readonly description?: string; readonly repoId?: string; readonly folderUri?: URI }[]): Promise<{ readonly label: string; readonly description?: string; readonly repoId?: string; readonly folderUri?: URI } | undefined> { 		this.repositoryPicks = repositories;-		return this.selectedRepository;+		return repositories.find(repository => (repository.repoId ?? repository.folderUri?.toString()) === this.selectedRepository); 	} } +function workspaceContextService(folders: readonly { readonly uri: URI; readonly name?: string }[] = []): IWorkspaceContextService {+	return new class extends mock<IWorkspaceContextService>() {+		override getWorkspace(): IWorkspace {+			return upcastPartial<IWorkspace>({+				folders: folders.map(folder => upcastPartial<IWorkspaceFolder>({+					uri: folder.uri,+					name: folder.name ?? folder.uri.path.split('/').pop() ?? folder.uri.path,+				})),+			});+		}++		override getWorkspaceFolder(resource: URI): IWorkspaceFolder | null {+			return this.getWorkspace().folders.find(folder => resource.toString().startsWith(folder.uri.toString())) ?? null;+		}+	}();+}+ suite('ChatContext', () => {  	const disposables = ensureNoDisposablesAreLeakedInTestSuite();@@ -108,6 +133,7 @@ suite('ChatContext', () => { 			new TestGitService([repository('https://example.com/owner/repository.git')]), 			new class extends mock<IQuickInputService>() { }(), 			commandService,+			workspaceContextService(), 		);  		await pick.asAttachment();@@ -118,7 +144,7 @@ suite('ChatContext', () => { 			enabled: true, 			command: { 				id: 'github.copilot.chat.cloudSessions.openIssue',-				repoId: undefined,+				repository: undefined, 			}, 		}); 	});@@ -135,6 +161,7 @@ suite('ChatContext', () => { 			new TestGitService([repository('https://github.com/microsoft/vscode.git')]), 			new class extends mock<IQuickInputService>() { }(), 			commandService,+			workspaceContextService(), 		);  		const attachment = await pick.asAttachment();@@ -149,7 +176,7 @@ suite('ChatContext', () => { 		}, { 			command: { 				id: 'github.copilot.chat.cloudSessions.openIssue',-				repoId: 'microsoft/vscode',+				repository: 'microsoft/vscode', 			}, 			attachment: { 				id: 'https://github.com/microsoft/vscode/issues/123',@@ -162,24 +189,93 @@ suite('ChatContext', () => {  	test('selects a repository before opening GitHub context picker for multiple repositories', async () => { 		const commandService = new TestCommandService();+		const repositories = [+			repository('git@github.com:microsoft/vscode.git', URI.file('/workspace/vscode')),+			repository('https://github.com/microsoft/typescript.git', URI.file('/workspace/typescript')),+		];+		const gitService = new TestGitService(repositories); 		const pick = new TestGitHubContextValuePick( 			'pullRequest',-			new TestGitService([-				repository('git@github.com:microsoft/vscode.git'),-				repository('https://github.com/microsoft/typescript.git'),+			gitService,+			new class extends mock<IQuickInputService>() { }(),+			commandService,+			workspaceContextService([+				{ uri: repositories[0].rootUri, name: 'VS Code' },+				{ uri: repositories[1].rootUri, name: 'TypeScript' }, 			]),+		);+		pick.selectedRepository = 'microsoft/vscode';++		await pick.asAttachment();+		assert.deepStrictEqual({+			repositoryPicks: pick.repositoryPicks,+			commandRepository: commandService.command?.repository,+			openRepositoryCalls: gitService.openRepositoryCalls,+		}, {+			repositoryPicks: [+				{ label: 'VS Code', description: 'microsoft/vscode', repoId: 'microsoft/vscode', folderUri: repositories[0].rootUri },+				{ label: 'TypeScript', description: 'microsoft/typescript', repoId: 'microsoft/typescript', folderUri: repositories[1].rootUri },+			],+			commandRepository: 'microsoft/vscode',+			openRepositoryCalls: [],+		});+	});++	test('selects a folder before opening GitHub context for multiple roots of the same repository', async () => {+		const commandService = new TestCommandService();+		const repositories = [+			repository('https://github.com/microsoft/vscode.git', URI.file('/workspace/client')),+			repository('https://github.com/microsoft/vscode.git', URI.file('/workspace/server')),+		];+		const pick = new TestGitHubContextValuePick(+			'issue',+			new TestGitService(repositories), 			new class extends mock<IQuickInputService>() { }(), 			commandService,+			workspaceContextService([+				{ uri: repositories[0].rootUri, name: 'Client' },+				{ uri: repositories[1].rootUri, name: 'Server' },+			]), 		); 		pick.selectedRepository = 'microsoft/vscode';  		await pick.asAttachment();++		assert.deepStrictEqual(pick.repositoryPicks, [+			{ label: 'Client', description: 'microsoft/vscode', repoId: 'microsoft/vscode', folderUri: repositories[0].rootUri },+			{ label: 'Server', description: 'microsoft/vscode', repoId: 'microsoft/vscode', folderUri: repositories[1].rootUri },+		]);+	});++	test('selects a folder before opening GitHub context when repository metadata is incomplete', async () => {+		const commandService = new TestCommandService();+		const repositoryRoot = URI.file('/workspace/vscode');+		const docsRoot = URI.file('/workspace/docs');+		const pick = new TestGitHubContextValuePick(+			'pullRequest',+			new TestGitService([+				repository('https://github.com/microsoft/vscode.git', repositoryRoot),+			]),+			new class extends mock<IQuickInputService>() { }(),+			commandService,+			workspaceContextService([+				{ uri: repositoryRoot, name: 'VS Code' },+				{ uri: docsRoot, name: 'Docs' },+			]),+		);+		pick.selectedRepository = docsRoot.toString();++		await pick.asAttachment();+ 		assert.deepStrictEqual({ 			repositoryPicks: pick.repositoryPicks,-			commandRepoId: commandService.command?.repoId,+			commandRepository: commandService.command?.repository, 		}, {-			repositoryPicks: ['microsoft/typescript', 'microsoft/vscode'],-			commandRepoId: 'microsoft/vscode',+			repositoryPicks: [+				{ label: 'VS Code', description: 'microsoft/vscode', repoId: 'microsoft/vscode', folderUri: repositoryRoot },+				{ label: 'Docs', folderUri: docsRoot },+			],+			commandRepository: docsRoot, 		}); 	}); @@ -190,6 +286,7 @@ suite('ChatContext', () => { 			new TestGitService([]), 			new class extends mock<IQuickInputService>() { }(), 			new TestCommandService(),+			workspaceContextService(), 		))); 		disposables.add(service.registerChatContextItem({ 			type: 'valuePick',