Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed a server-side memory leak where a single shared react-query cache retained state from every server render; the cache is now created per-request. [#1575](https://github.com/sourcebot-dev/sourcebot/pull/1575)
- Fixed code host retry warnings to include the HTTP response status. [#1576](https://github.com/sourcebot-dev/sourcebot/pull/1576)
- Fixed streamed code search updates silently cancelling in-flight result navigation. [#1577](https://github.com/sourcebot-dev/sourcebot/pull/1577)
- Fixed the `grep` and `glob` agent tools mis-parsing structured search inputs containing spaces, commas, or quotes. [#1573](https://github.com/sourcebot-dev/sourcebot/pull/1573)

## [5.1.6] - 2026-08-10

Expand Down
64 changes: 64 additions & 0 deletions packages/web/src/features/tools/glob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
search: vi.fn(),
getRepoInfoByName: vi.fn(),
}));

vi.mock('@/features/search', () => ({
search: mocks.search,
}));

vi.mock('@/actions', () => ({
getRepoInfoByName: mocks.getRepoInfoByName,
}));

vi.mock('@/lib/utils', () => ({
isServiceError: () => false,
}));

vi.mock('./logger', () => ({
logger: { debug: vi.fn() },
}));

import { globDefinition } from './glob';
import { buildGlobSearchQuery } from './searchQuery';

const emptySearchResponse = {
files: [],
repositoryInfo: [],
stats: { actualMatchCount: 0 },
isSearchExhaustive: true,
};

describe('glob', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.search.mockResolvedValue(emptySearchResponse);
});

it('executes with QueryIR', async () => {
const result = await globDefinition.execute({
pattern: 'My Folder/**/*.ts',
ref: 'feature/my feature',
}, {
source: 'test',
selectedRepos: ['Repo One'],
});

expect(mocks.search).toHaveBeenCalledWith({
queryType: 'ir',
query: buildGlobSearchQuery({
pattern: 'My Folder/**/*.ts',
ref: 'feature/my feature',
selectedRepos: ['Repo One'],
}),
options: {
matches: 100,
contextLines: 0,
},
source: 'test',
});
expect(result.metadata).not.toHaveProperty('query');
});
});
36 changes: 9 additions & 27 deletions packages/web/src/features/tools/glob.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import { z } from "zod";
import globToRegexp from "glob-to-regexp";
import { isServiceError } from "@/lib/utils";
import { search } from "@/features/search";
import escapeStringRegexp from "escape-string-regexp";
import { Source, ToolDefinition } from "./types";
import { logger } from "./logger";
import description from "./glob.txt";
import { CodeHostType } from "@sourcebot/db";
import { getRepoInfoByName } from "@/actions";
import { buildGlobSearchQuery } from "./searchQuery";

const DEFAULT_LIMIT = 100;
const TRUNCATION_MESSAGE = `(Results truncated. Consider using a more specific pattern, specifying a repo, or increasing the limit.)`;

function globToFileRegexp(glob: string): string {
const re = globToRegexp(glob, { extended: true, globstar: true });
return re.source.replace(/^\^/, '');
}

const globShape = {
pattern: z
.string()
Expand Down Expand Up @@ -55,7 +49,6 @@ export type GlobRepoInfo = {
export type GlobMetadata = {
files: GlobFile[];
pattern: string;
query: string;
fileCount: number;
repoCount: number;
repoInfoMap: Record<string, GlobRepoInfo>;
Expand All @@ -81,30 +74,20 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada

logger.debug('glob', { pattern, repo, ref, path, limit });

let query = `file:${globToFileRegexp(pattern)}`;

if (path) {
query += ` file:${escapeStringRegexp(path)}`;
}

if (repo) {
query += ` repo:${escapeStringRegexp(repo)}`;
} else if (context.selectedRepos && context.selectedRepos.length > 0) {
query += ` reposet:${context.selectedRepos.join(',')}`;
}

if (ref) {
query += ` rev:${ref}`;
}
const query = buildGlobSearchQuery({
pattern,
path,
repo,
ref,
selectedRepos: context.selectedRepos,
});

const response = await search({
queryType: 'string',
queryType: 'ir',
query,
options: {
matches: limit,
contextLines: 0,
isCaseSensitivityEnabled: true,
isRegexEnabled: true,
},
source: context.source,
});
Expand Down Expand Up @@ -144,7 +127,6 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada
const metadata: GlobMetadata = {
files,
pattern,
query,
fileCount: files.length,
repoCount: new Set(files.map((f) => f.repo)).size,
repoInfoMap,
Expand Down
65 changes: 65 additions & 0 deletions packages/web/src/features/tools/grep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
search: vi.fn(),
getRepoInfoByName: vi.fn(),
}));

vi.mock('@/features/search', () => ({
search: mocks.search,
}));

vi.mock('@/actions', () => ({
getRepoInfoByName: mocks.getRepoInfoByName,
}));

vi.mock('@/lib/utils', () => ({
isServiceError: () => false,
}));

vi.mock('./logger', () => ({
logger: { debug: vi.fn() },
}));

import { grepDefinition } from './grep';
import { buildGrepSearchQuery } from './searchQuery';

const emptySearchResponse = {
files: [],
repositoryInfo: [],
stats: { actualMatchCount: 0 },
isSearchExhaustive: true,
};

describe('grep', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.search.mockResolvedValue(emptySearchResponse);
});

it('executes with QueryIR', async () => {
const result = await grepDefinition.execute({
pattern: 'needle',
path: 'src/my dir',
limit: 25,
}, {
source: 'test',
selectedRepos: ['Repo One'],
});

expect(mocks.search).toHaveBeenCalledWith({
queryType: 'ir',
query: buildGrepSearchQuery({
pattern: 'needle',
path: 'src/my dir',
selectedRepos: ['Repo One'],
}),
options: {
matches: 25,
contextLines: 0,
},
source: 'test',
});
expect(result.metadata).not.toHaveProperty('query');
});
});
42 changes: 10 additions & 32 deletions packages/web/src/features/tools/grep.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import { z } from "zod";
import globToRegexp from "glob-to-regexp";
import { isServiceError } from "@/lib/utils";
import { search } from "@/features/search";
import escapeStringRegexp from "escape-string-regexp";
import { Source, ToolDefinition } from "./types";
import { logger } from "./logger";
import description from "./grep.txt";
import { CodeHostType } from "@sourcebot/db";
import { getRepoInfoByName } from "@/actions";
import { buildGrepSearchQuery } from "./searchQuery";

const DEFAULT_LIMIT = 100;
const DEFAULT_GROUP_BY_REPO_LIMIT = 10_000;
const MAX_LINE_LENGTH = 2000;
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
const TRUNCATION_MESSAGE = `(Results truncated. Consider using a more specific path or pattern, specifying a repo, or increasing the limit.)`;

function globToFileRegexp(glob: string): string {
const re = globToRegexp(glob, { extended: true, globstar: true });
return re.source.replace(/^\^/, '');
}

const grepShape = {
pattern: z
.string()
Expand Down Expand Up @@ -66,7 +60,6 @@ export type GrepRepoInfo = {
export type GrepMetadata = {
files: GrepFile[];
pattern: string;
query: string;
matchCount: number;
repoCount: number;
repoInfoMap: Record<string, GrepRepoInfo>;
Expand Down Expand Up @@ -95,35 +88,21 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada

logger.debug('grep', { pattern, path, include, repo, ref, limit, groupByRepo });

const quotedPattern = `"${pattern.replace(/"/g, '\\"')}"`;
let query = quotedPattern;

if (path) {
query += ` file:${escapeStringRegexp(path)}`;
}

if (include) {
query += ` file:${globToFileRegexp(include)}`;
}

if (repo) {
query += ` repo:${escapeStringRegexp(repo)}`;
} else if (context.selectedRepos && context.selectedRepos.length > 0) {
query += ` reposet:${context.selectedRepos.join(',')}`;
}

if (ref) {
query += ` rev:${ref}`;
}
const query = buildGrepSearchQuery({
pattern,
path,
include,
repo,
ref,
selectedRepos: context.selectedRepos,
});

const response = await search({
queryType: 'string',
queryType: 'ir',
query,
options: {
matches: limit,
contextLines: 0,
isCaseSensitivityEnabled: true,
isRegexEnabled: true,
},
source: context.source,
});
Expand Down Expand Up @@ -161,7 +140,6 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada
const metadata: GrepMetadata = {
files,
pattern,
query,
matchCount: response.stats.actualMatchCount,
repoCount: new Set(files.map((f) => f.repo)).size,
repoInfoMap,
Expand Down
Loading
Loading