Skip to content

Commit 90aecdc

Browse files
authored
feat(search): add global search entity with CQRS browse_search tool (#93)
Implements issue #82 - global search functionality. browse_search tool actions: - global: Search across entire GitLab instance - project: Search within a specific project (with ref support for code) - group: Search within a group and its subgroups Search scopes: projects, issues, merge_requests, milestones, snippet_titles, users, groups, blobs (code), commits, wiki_blobs, notes Features: - State filtering for issues/merge_requests (opened, closed, merged, all) - Confidential filter for issues (Premium tier) - Sorting by created_at/updated_at with asc/desc direction - Standard pagination (page, per_page) - Ref parameter for project code search All actions available in Free tier. Unit tests with 100% coverage.
1 parent b1113d7 commit 90aecdc

File tree

9 files changed

+1069
-0
lines changed

9 files changed

+1069
-0
lines changed

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export const USE_INTEGRATIONS = process.env.USE_INTEGRATIONS !== "false";
131131
export const USE_RELEASES = process.env.USE_RELEASES !== "false";
132132
export const USE_REFS = process.env.USE_REFS !== "false";
133133
export const USE_MEMBERS = process.env.USE_MEMBERS !== "false";
134+
export const USE_SEARCH = process.env.USE_SEARCH !== "false";
134135
export const HOST = process.env.HOST ?? "0.0.0.0";
135136
export const PORT = process.env.PORT ?? 3002;
136137

src/entities/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ export * from "./webhooks";
1212
export * from "./releases";
1313
export * from "./refs";
1414
export * from "./members";
15+
export * from "./search";
1516

1617
// All entities now use the registry pattern with embedded handlers

src/entities/search/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Search entity - GitLab global and scoped search
2+
export * from "./schema-readonly";
3+
export {
4+
searchToolRegistry,
5+
getSearchReadOnlyToolNames,
6+
getSearchToolDefinitions,
7+
getFilteredSearchTools,
8+
} from "./registry";

src/entities/search/registry.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import * as z from "zod";
2+
import { BrowseSearchSchema } from "./schema-readonly";
3+
import { ToolRegistry, EnhancedToolDefinition } from "../../types";
4+
import { isActionDenied } from "../../config";
5+
import { gitlab, paths, toQuery } from "../../utils/gitlab-api";
6+
7+
/**
8+
* Search tools registry - 1 read-only CQRS tool
9+
*
10+
* browse_search (Query): global, project, group
11+
*
12+
* Search is read-only by design - no manage_search tool needed.
13+
*/
14+
export const searchToolRegistry: ToolRegistry = new Map<string, EnhancedToolDefinition>([
15+
// ============================================================================
16+
// browse_search - CQRS Query Tool (discriminated union schema)
17+
// TypeScript automatically narrows types in each switch case
18+
// ============================================================================
19+
[
20+
"browse_search",
21+
{
22+
name: "browse_search",
23+
description:
24+
'SEARCH GitLab resources. Actions: "global" searches entire instance, "project" searches within a project, "group" searches within a group. Scopes: projects, issues, merge_requests, milestones, users, groups, blobs (code), commits, wiki_blobs, notes.',
25+
inputSchema: z.toJSONSchema(BrowseSearchSchema),
26+
gate: { envVar: "USE_SEARCH", defaultValue: true },
27+
handler: async (args: unknown): Promise<unknown> => {
28+
const input = BrowseSearchSchema.parse(args);
29+
30+
// Runtime validation: reject denied actions even if they bypass schema filtering
31+
if (isActionDenied("browse_search", input.action)) {
32+
throw new Error(`Action '${input.action}' is not allowed for browse_search tool`);
33+
}
34+
35+
switch (input.action) {
36+
case "global": {
37+
// TypeScript knows: input has scope, search, and optional filters
38+
const { scope, ...params } = input;
39+
40+
// Build query params excluding action (not an API parameter)
41+
const query = toQuery(params, ["action"]);
42+
43+
// Global search endpoint
44+
const results = await gitlab.get<unknown[]>("search", {
45+
query: { ...query, scope },
46+
});
47+
48+
return {
49+
scope,
50+
count: results.length,
51+
results,
52+
};
53+
}
54+
55+
case "project": {
56+
// TypeScript knows: input has project_id, scope, search, and optional filters
57+
const { project_id, scope, ref, ...params } = input;
58+
59+
// Build query params excluding action (project_id, scope, ref are already destructured)
60+
const query = toQuery(params, ["action"]);
61+
62+
// Project-scoped search endpoint
63+
const results = await gitlab.get<unknown[]>(`${paths.project(project_id)}/search`, {
64+
query: { ...query, scope, ...(ref && { ref }) },
65+
});
66+
67+
return {
68+
project_id,
69+
scope,
70+
count: results.length,
71+
results,
72+
};
73+
}
74+
75+
case "group": {
76+
// TypeScript knows: input has group_id, scope, search, and optional filters
77+
const { group_id, scope, ...params } = input;
78+
79+
// Build query params excluding action (group_id, scope are already destructured)
80+
const query = toQuery(params, ["action"]);
81+
82+
// Group-scoped search endpoint
83+
const results = await gitlab.get<unknown[]>(`${paths.group(group_id)}/search`, {
84+
query: { ...query, scope },
85+
});
86+
87+
return {
88+
group_id,
89+
scope,
90+
count: results.length,
91+
results,
92+
};
93+
}
94+
95+
/* istanbul ignore next -- unreachable with Zod discriminatedUnion */
96+
default:
97+
throw new Error(`Unknown action: ${(input as { action: string }).action}`);
98+
}
99+
},
100+
},
101+
],
102+
]);
103+
104+
/**
105+
* Get read-only tool names from the registry
106+
* Search is entirely read-only, so all tools are read-only
107+
*/
108+
export function getSearchReadOnlyToolNames(): string[] {
109+
return ["browse_search"];
110+
}
111+
112+
/**
113+
* Get all tool definitions from the registry
114+
*/
115+
export function getSearchToolDefinitions(): EnhancedToolDefinition[] {
116+
return Array.from(searchToolRegistry.values());
117+
}
118+
119+
/**
120+
* Get filtered tools based on read-only mode
121+
* Since search is read-only, this always returns all tools
122+
*/
123+
export function getFilteredSearchTools(readOnlyMode: boolean = false): EnhancedToolDefinition[] {
124+
// Search is always read-only, so readOnlyMode doesn't affect filtering
125+
void readOnlyMode;
126+
return getSearchToolDefinitions();
127+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { z } from "zod";
2+
import { PaginationOptionsSchema } from "../shared";
3+
import { requiredId } from "../utils";
4+
5+
// ============================================================================
6+
// Search scope enum - GitLab API search scopes
7+
// ============================================================================
8+
9+
export const SearchScopeSchema = z
10+
.enum([
11+
"projects",
12+
"issues",
13+
"merge_requests",
14+
"milestones",
15+
"snippet_titles",
16+
"users",
17+
"groups",
18+
"blobs",
19+
"commits",
20+
"wiki_blobs",
21+
"notes",
22+
])
23+
.describe("Search scope determining what type of resources to search");
24+
25+
// ============================================================================
26+
// Base search parameters shared across all scopes
27+
// ============================================================================
28+
29+
const BaseSearchParams = z.object({
30+
search: z.string().min(1).describe("Search query string (minimum 1 character)"),
31+
state: z
32+
.enum(["opened", "closed", "merged", "all"])
33+
.optional()
34+
.describe("Filter by state (for issues and merge_requests scopes)"),
35+
confidential: z
36+
.boolean()
37+
.optional()
38+
.describe("Filter by confidentiality (for issues scope, Premium only)"),
39+
order_by: z.enum(["created_at", "updated_at"]).optional().describe("Sort results by field"),
40+
sort: z.enum(["asc", "desc"]).optional().describe("Sort direction"),
41+
});
42+
43+
// ============================================================================
44+
// browse_search - CQRS Query Tool (discriminated union schema)
45+
// Actions: global, project, group
46+
// Uses z.discriminatedUnion() for type-safe action handling.
47+
// ============================================================================
48+
49+
// --- Action: global ---
50+
const GlobalSearchSchema = z
51+
.object({
52+
action: z.literal("global").describe("Search across entire GitLab instance"),
53+
scope: SearchScopeSchema,
54+
})
55+
.merge(BaseSearchParams)
56+
.merge(PaginationOptionsSchema);
57+
58+
// --- Action: project ---
59+
const ProjectSearchSchema = z
60+
.object({
61+
action: z.literal("project").describe("Search within a specific project"),
62+
project_id: requiredId.describe(
63+
"Project ID or URL-encoded path (e.g., 'group/project' or '123')"
64+
),
65+
scope: SearchScopeSchema,
66+
ref: z.string().optional().describe("Branch/tag reference for code search (blobs, commits)"),
67+
})
68+
.merge(BaseSearchParams)
69+
.merge(PaginationOptionsSchema);
70+
71+
// --- Action: group ---
72+
const GroupSearchSchema = z
73+
.object({
74+
action: z.literal("group").describe("Search within a specific group and its subgroups"),
75+
group_id: requiredId.describe("Group ID or URL-encoded path (e.g., 'my-group' or '123')"),
76+
scope: SearchScopeSchema,
77+
})
78+
.merge(BaseSearchParams)
79+
.merge(PaginationOptionsSchema);
80+
81+
// --- Discriminated union combining all actions ---
82+
export const BrowseSearchSchema = z.discriminatedUnion("action", [
83+
GlobalSearchSchema,
84+
ProjectSearchSchema,
85+
GroupSearchSchema,
86+
]);
87+
88+
// ============================================================================
89+
// Type exports
90+
// ============================================================================
91+
92+
export type SearchScope = z.infer<typeof SearchScopeSchema>;
93+
export type BrowseSearchInput = z.infer<typeof BrowseSearchSchema>;
94+
export type GlobalSearchInput = z.infer<typeof GlobalSearchSchema>;
95+
export type ProjectSearchInput = z.infer<typeof ProjectSearchSchema>;
96+
export type GroupSearchInput = z.infer<typeof GroupSearchSchema>;

src/registry-manager.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { releasesToolRegistry, getReleasesReadOnlyToolNames } from "./entities/releases/registry";
3030
import { refsToolRegistry, getRefsReadOnlyToolNames } from "./entities/refs/registry";
3131
import { membersToolRegistry, getMembersReadOnlyToolNames } from "./entities/members/registry";
32+
import { searchToolRegistry, getSearchReadOnlyToolNames } from "./entities/search/registry";
3233
import {
3334
GITLAB_READ_ONLY_MODE,
3435
GITLAB_DENIED_TOOLS_REGEX,
@@ -46,6 +47,7 @@ import {
4647
USE_RELEASES,
4748
USE_REFS,
4849
USE_MEMBERS,
50+
USE_SEARCH,
4951
getToolDescriptionOverrides,
5052
} from "./config";
5153
import { ToolAvailability } from "./services/ToolAvailability";
@@ -152,6 +154,10 @@ class RegistryManager {
152154
this.registries.set("members", membersToolRegistry);
153155
}
154156

157+
if (USE_SEARCH) {
158+
this.registries.set("search", searchToolRegistry);
159+
}
160+
155161
// All entity registries have been migrated to the new pattern!
156162
}
157163

@@ -235,6 +241,10 @@ class RegistryManager {
235241
readOnlyTools.push(...getMembersReadOnlyToolNames());
236242
}
237243

244+
if (USE_SEARCH) {
245+
readOnlyTools.push(...getSearchReadOnlyToolNames());
246+
}
247+
238248
return readOnlyTools;
239249
}
240250

@@ -394,6 +404,7 @@ class RegistryManager {
394404
const useReleases = process.env.USE_RELEASES !== "false";
395405
const useRefs = process.env.USE_REFS !== "false";
396406
const useMembers = process.env.USE_MEMBERS !== "false";
407+
const useSearch = process.env.USE_SEARCH !== "false";
397408

398409
// Build registries map based on dynamic feature flags
399410
const registriesToUse = new Map<string, ToolRegistry>();
@@ -416,6 +427,7 @@ class RegistryManager {
416427
if (useReleases) registriesToUse.set("releases", releasesToolRegistry);
417428
if (useRefs) registriesToUse.set("refs", refsToolRegistry);
418429
if (useMembers) registriesToUse.set("members", membersToolRegistry);
430+
if (useSearch) registriesToUse.set("search", searchToolRegistry);
419431

420432
// Dynamically load description overrides
421433
const descOverrides = getToolDescriptionOverrides();
@@ -483,6 +495,7 @@ class RegistryManager {
483495
releasesToolRegistry,
484496
refsToolRegistry,
485497
membersToolRegistry,
498+
searchToolRegistry,
486499
];
487500

488501
for (const registry of allRegistries) {

src/services/ToolAvailability.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,16 @@ export class ToolAvailability {
681681
update_group: { tier: "free", minVersion: 8.0, notes: "member_role_id requires Ultimate" },
682682
},
683683
},
684+
685+
// Search (read-only)
686+
browse_search: {
687+
default: { tier: "free", minVersion: 8.0 },
688+
actions: {
689+
global: { tier: "free", minVersion: 8.0, notes: "Global search across GitLab instance" },
690+
project: { tier: "free", minVersion: 8.0, notes: "Project-scoped search" },
691+
group: { tier: "free", minVersion: 10.5, notes: "Group-scoped search" },
692+
},
693+
},
684694
};
685695

686696
/**

0 commit comments

Comments
 (0)