diff --git a/src/lm/tools/searchTools.ts b/src/lm/tools/searchTools.ts index 92d623e7c5..c185ee6cea 100644 --- a/src/lm/tools/searchTools.ts +++ b/src/lm/tools/searchTools.ts @@ -59,8 +59,23 @@ export class SearchTool extends RepoToolBase { return `https://github.com/issues/?q=${encodeURIComponent(query)}`; } + private toRepositoryQuery(query: string, owner?: string, name?: string) { + if (!owner || !name) { + return query; + } + + const repositoryQualifier = `repo:${owner}/${name}`; + const scopedQuery = query.replace( + /(?^|[\s(])repo:(?"[\w.-]+\/[\w.-]+"|[\w.-]+\/[\w.-]+)(?=\s|\)|$)/gi, + (_match, prefix: string) => { + return `${prefix}${repositoryQualifier}`; + }, + ); + return `(${scopedQuery}) AND ${repositoryQualifier}`; + } + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - const parameterQuery = options.input.query; + const parameterQuery = this.toRepositoryQuery(options.input.query, options.input.repo?.owner, options.input.repo?.name); const message = new vscode.MarkdownString(); message.appendText(vscode.l10n.t('Searching for issues with "{0}".', parameterQuery)); message.appendMarkdown(vscode.l10n.t(' [Open on GitHub.com]({0})', escapeMarkdown(this.toGitHubUrl(parameterQuery)))); @@ -71,19 +86,19 @@ export class SearchTool extends RepoToolBase { } async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { - const { folderManager } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name }); + const { owner, name, folderManager } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name, exact: true }); - const parameterQuery = options.input.query; + const parameterQuery = this.toRepositoryQuery(options.input.query, owner, name); Logger.debug(`Searching with query \`${parameterQuery}\``, SearchTool.ID); - const searchResult = await folderManager.getIssues(parameterQuery); + const githubRepository = folderManager.findExistingGitHubRepository({ owner, repositoryName: name }); + const searchResult = await githubRepository?.getIssues(undefined, parameterQuery); if (!searchResult) { - throw new Error(`No issues found for ${parameterQuery}. Make sure the query is valid.`); + throw new Error(`Unable to search issues in ${owner}/${name}. Make sure the repository is accessible and the query is valid.`); } const cutoff = 30; const result: SearchToolResult = { - arrayOfIssues: searchResult.items.slice(0, cutoff).map(i => { - const item = i.item; + arrayOfIssues: searchResult.items.slice(0, cutoff).map(item => { return { title: item.title, url: item.url, diff --git a/src/lm/tools/toolsUtils.ts b/src/lm/tools/toolsUtils.ts index 07692d35d3..f8a2ed428c 100644 --- a/src/lm/tools/toolsUtils.ts +++ b/src/lm/tools/toolsUtils.ts @@ -53,16 +53,19 @@ export abstract class RepoToolBase extends ToolBase { super(); } - protected async getRepoInfo(options: { owner?: string, name?: string }): Promise<{ owner: string; name: string; folderManager: FolderRepositoryManager }> { + protected async getRepoInfo(options: { owner?: string, name?: string, exact?: boolean }): Promise<{ owner: string; name: string; folderManager: FolderRepositoryManager }> { if (!this.credentialStore.isAnyAuthenticated()) { throw new AuthenticationError(); } + if (options.exact && (!options.owner || !options.name)) { + throw new Error('Repository owner and name are required.'); + } let owner: string | undefined; let name: string | undefined; let folderManager: FolderRepositoryManager | undefined; // The llm likes to make up an owner and name if it isn't provided one, and they tend to include 'owner' and 'name' respectively - if (options.owner && options.name && !options.owner.includes('owner') && !options.name.includes('name')) { + if (options.owner && options.name && (options.exact || (!options.owner.includes('owner') && !options.name.includes('name')))) { owner = options.owner; name = options.name; folderManager = this.repositoriesManager.getManagerForRepository(options.owner, options.name); diff --git a/src/test/lm/tools/searchTools.test.ts b/src/test/lm/tools/searchTools.test.ts new file mode 100644 index 0000000000..a68025d90d --- /dev/null +++ b/src/test/lm/tools/searchTools.test.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CredentialStore } from '../../../github/credentials'; +import { FolderRepositoryManager } from '../../../github/folderRepositoryManager'; +import { GitHubRepository } from '../../../github/githubRepository'; +import { AccountType, Issue } from '../../../github/interface'; +import { RepositoriesManager } from '../../../github/repositoriesManager'; +import { SearchTool, SearchToolResult } from '../../../lm/tools/searchTools'; + +describe('SearchTool', function () { + let sinon: SinonSandbox; + + beforeEach(function () { + sinon = createSandbox(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('uses the explicit repository when the query has a repository qualifier', async function () { + const tool = new SearchTool({} as CredentialStore, {} as RepositoriesManager); + + const invocation = await tool.prepareInvocation({ + input: { + repo: { owner: 'microsoft', name: 'vscode' }, + query: '(repo:"other/repository" OR label:bug)', + }, + }); + + const message = invocation.invocationMessage as vscode.MarkdownString; + assert.strictEqual(message.value.match(/repo:microsoft\/vscode/g)?.length, 2); + assert.strictEqual(message.value.includes('repo:other/repository'), false); + }); + + it('searches the explicit repository when it is not an active workspace remote', async function () { + const credentialStore = sinon.createStubInstance(CredentialStore); + credentialStore.isAnyAuthenticated.returns(true); + const repositoriesManager = sinon.createStubInstance(RepositoriesManager); + const folderManager = sinon.createStubInstance(FolderRepositoryManager); + const githubRepository = sinon.createStubInstance(GitHubRepository); + const issue: Issue = { + id: 332663, + graphNodeId: 'I_332663', + title: 'Known matching issue', + titleHTML: 'Known matching issue', + body: '', + url: 'https://github.com/repository-owner/repository-name/issues/332663', + number: 332663, + labels: [], + state: 'OPEN', + assignees: [{ login: 'jruales', id: 'jruales', url: 'https://github.com/jruales', accountType: AccountType.User }], + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', + user: { login: 'author', id: 'author', url: 'https://github.com/author', accountType: AccountType.User }, + commentCount: 0, + reactionCount: 0, + reactions: [], + }; + + repositoriesManager.getManagerForRepository.returns(folderManager as unknown as FolderRepositoryManager); + folderManager.findExistingGitHubRepository.returns(githubRepository as unknown as GitHubRepository); + githubRepository.getIssues.resolves({ + items: [issue], + hasMorePages: false, + totalCount: 1, + }); + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await new SearchTool( + credentialStore as unknown as CredentialStore, + repositoriesManager as unknown as RepositoriesManager, + ).invoke({ + input: { + repo: { owner: 'repository-owner', name: 'repository-name' }, + query: 'is:issue is:open assignee:jruales', + }, + toolInvocationToken: undefined, + }, tokenSource.token); + tokenSource.dispose(); + + assert.strictEqual(repositoriesManager.getManagerForRepository.calledOnceWith('repository-owner', 'repository-name'), true); + assert.strictEqual(githubRepository.getIssues.calledOnceWith( + undefined, + '(is:issue is:open assignee:jruales) AND repo:repository-owner/repository-name', + ), true); + assert.strictEqual(folderManager.getIssues.notCalled, true); + const searchResult = JSON.parse((result!.content[0] as vscode.LanguageModelTextPart).value) as SearchToolResult; + assert.strictEqual(searchResult.totalIssues, 1); + assert.strictEqual(searchResult.arrayOfIssues![0].number, 332663); + }); +});