microsoft/vscode · #334594
chat: fix GitHub context repository selection
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',