Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions src/lm/tools/searchTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,23 @@ export class SearchTool extends RepoToolBase<SearchToolParameters> {
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(
/(?<prefix>^|[\s(])repo:(?<repository>"[\w.-]+\/[\w.-]+"|[\w.-]+\/[\w.-]+)(?=\s|\)|$)/gi,
(_match, prefix: string) => {
return `${prefix}${repositoryQualifier}`;
},
);
return `(${scopedQuery}) AND ${repositoryQualifier}`;
}

async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions<SearchToolParameters>): Promise<vscode.PreparedToolInvocation> {
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))));
Expand All @@ -71,19 +86,19 @@ export class SearchTool extends RepoToolBase<SearchToolParameters> {
}

async invoke(options: vscode.LanguageModelToolInvocationOptions<SearchToolParameters>, _token: vscode.CancellationToken): Promise<vscode.LanguageModelToolResult | undefined> {
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,
Expand Down
7 changes: 5 additions & 2 deletions src/lm/tools/toolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,19 @@ export abstract class RepoToolBase<T> extends ToolBase<T> {
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);
Expand Down
99 changes: 99 additions & 0 deletions src/test/lm/tools/searchTools.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});