diff --git a/src/registry/toolsets/gitops.ts b/src/registry/toolsets/gitops.ts index 138b497b..976f0af7 100644 --- a/src/registry/toolsets/gitops.ts +++ b/src/registry/toolsets/gitops.ts @@ -71,6 +71,40 @@ function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" ? value : undefined; +} + +function gitopsListExtract(...arrayKeys: string[]) { + return (raw: unknown): unknown => { + if (!isRecord(raw)) return raw; + + let items: unknown[] | undefined = Array.isArray(raw.items) ? raw.items : undefined; + for (const key of arrayKeys) { + if (items) break; + const value = raw[key]; + if (Array.isArray(value)) { + items = value; + } + } + + if (!items) return raw; + + const pagination = isRecord(raw.pagination) ? raw.pagination : undefined; + const total = + numberField(raw, "total") ?? + numberField(raw, "totalItems") ?? + numberField(raw, "totalElements") ?? + numberField(raw, "itemCount") ?? + (pagination ? numberField(pagination, "totalItems") : undefined) ?? + (pagination ? numberField(pagination, "total") : undefined) ?? + items.length; + + return { ...raw, items, total }; + }; +} + /** * Encode `apiextensionsv1.JSON` fields inside a generator's `list.elements` * and `plugin.input.parameters`. Shared by top-level and nested encoders. @@ -208,7 +242,7 @@ export const gitopsToolset: ToolsetDefinition = { page: "pageIndex", size: "pageSize", }, - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("agents"), description: "List GitOps agents", }, get: { @@ -253,7 +287,7 @@ export const gitopsToolset: ToolsetDefinition = { operationPolicy: { risk: "read", retryPolicy: "safe" }, injectAccountInBody: true, bodyBuilder: (input) => gitopsListBody(input, { metadataOnly: true }), - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("applications"), description: "List GitOps applications in the project", }, get: { @@ -581,7 +615,7 @@ export const gitopsToolset: ToolsetDefinition = { operationPolicy: { risk: "read", retryPolicy: "safe" }, injectAccountInBody: true, bodyBuilder: (input) => gitopsListBody(input), - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("clusters"), description: "List GitOps clusters (scope depends on org_id/project_id presence)", }, get: { @@ -626,7 +660,7 @@ export const gitopsToolset: ToolsetDefinition = { operationPolicy: { risk: "read", retryPolicy: "safe" }, injectAccountInBody: true, bodyBuilder: (input) => gitopsListBody(input, { repoCredsId: input.repo_creds_id ?? "" }), - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("repositories"), description: "List GitOps repositories (scope depends on org_id/project_id presence)", }, get: { @@ -675,7 +709,7 @@ export const gitopsToolset: ToolsetDefinition = { operationPolicy: { risk: "read", retryPolicy: "safe" }, injectAccountInBody: true, bodyBuilder: (input) => gitopsListBody(input, input.agent_id ? { agentIdentifier: input.agent_id } : {}), - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("applicationsets", "applicationSets"), emptyOnErrorPatterns: [/agent is not registered/, /never connected/, /Not Implemented/], description: "List GitOps ApplicationSets", }, @@ -875,7 +909,7 @@ export const gitopsToolset: ToolsetDefinition = { operationPolicy: { risk: "read", retryPolicy: "safe" }, injectAccountInBody: true, bodyBuilder: (input) => gitopsListBody(input, input.agent_id ? { agentIdentifier: input.agent_id } : {}), - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("repoCreds", "repositoryCredentials", "credentials"), emptyOnErrorPatterns: [/agent is not registered/, /never connected/, /Not Implemented/], description: "List GitOps repository credentials", }, @@ -918,7 +952,7 @@ export const gitopsToolset: ToolsetDefinition = { agent_id: "agentIdentifier", app_name: "appName", }, - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("events"), description: "List events for a GitOps application", }, }, @@ -988,7 +1022,7 @@ export const gitopsToolset: ToolsetDefinition = { agent_id: "agentIdentifier", app_name: "appName", }, - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("resources", "managedResources"), description: "List managed resources for a GitOps application", }, }, @@ -1029,7 +1063,7 @@ export const gitopsToolset: ToolsetDefinition = { group: "request.group", version: "request.version", }, - responseExtractor: passthrough, + responseExtractor: gitopsListExtract("actions", "resourceActions"), description: "List available actions for a resource in a GitOps application", }, }, diff --git a/tasks/todo.md b/tasks/todo.md index 08008820..2522ee8a 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -263,3 +263,21 @@ - Clarified in README that hosted `https://mcp.harness.io/mcp` is managed and cannot be pointed at Harness0 from Claude/Cursor/Cowork client config; Support must configure hosted MCP for that target environment. - Updated MCPB manifest descriptions so `HARNESS_BASE_URL` covers private SaaS hosts such as `https://harness0.harness.io`, not just self-managed installs. - Verified with `pnpm build` and `pnpm docs:check`. + +## Slack Bug Triage: GitOps Search Returns Zero (2026-05-08) +- [x] Read the Slack thread and confirm available context +- [x] Trace GitOps list/search request and response handling +- [x] Add a focused failing regression test +- [x] Normalize GitOps list response shapes for search/list consumers +- [x] Run focused tests and typecheck +- [x] Commit, push, open PR, and reply in the Slack thread + +### Plan +- Reproduce the report with `harness_search(resource_types=["gitops_application"], org_id, project_id)` against the GitOps list response shape. +- Keep the fix in the GitOps toolset by normalizing list responses to expose `items` while preserving raw API fields. +- Verify the search test plus registry/toolset behavior so scoped explicit list and search use the same data. + +### Review +- Root cause: GitOps list APIs returned arrays under API-specific keys such as `applications`, but `harness_search` only counted normalized `items`. +- Added a GitOps list extractor that preserves raw keys while adding `items` and `total` for list/search consumers. +- Verified with focused GitOps search/list regressions, full `pnpm test`, `pnpm typecheck`, and `pnpm build`. diff --git a/tests/registry/registry.test.ts b/tests/registry/registry.test.ts index 19ad2216..b0cb2459 100644 --- a/tests/registry/registry.test.ts +++ b/tests/registry/registry.test.ts @@ -397,6 +397,25 @@ describe("Registry", () => { }); }); + it("normalizes GitOps application list arrays while preserving the raw response key", async () => { + const gitopsRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: "gitops" })); + const mockRequest = vi.fn().mockResolvedValue({ + applications: [{ name: "takeda-gitops-app" }], + total: 1, + }); + const client = makeClient(mockRequest); + + const result = (await gitopsRegistry.dispatch(client, "gitops_application", "list", { + org_id: "takeda-org", + project_id: "takeda-project", + search_term: "takeda", + })) as { applications: unknown[]; items: unknown[]; total: number }; + + expect(result.applications[0]).toMatchObject({ name: "takeda-gitops-app" }); + expect(result.items[0]).toMatchObject({ name: "takeda-gitops-app" }); + expect(result.total).toBe(1); + }); + it("builds correct path with path params for a get operation", async () => { const mockRequest = vi.fn().mockResolvedValue({ data: { identifier: "my-pipeline" } }); const client = makeClient(mockRequest); diff --git a/tests/tools/tool-handlers.test.ts b/tests/tools/tool-handlers.test.ts index bb05943d..5195e519 100644 --- a/tests/tools/tool-handlers.test.ts +++ b/tests/tools/tool-handlers.test.ts @@ -972,6 +972,48 @@ describe("harness_search", () => { expect(data.searched_types).toBe(1); }); + it("finds scoped GitOps applications returned under the API-specific applications key", async () => { + registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "gitops" })); + mockRequest = vi.fn().mockResolvedValue({ + applications: [ + { + name: "takeda-gitops-app", + orgIdentifier: "takeda-org", + projectIdentifier: "takeda-project", + }, + ], + total: 1, + }); + client = makeClient(mockRequest); + const { registerSearchTool } = await import("../../src/tools/harness-search.js"); + server = makeMcpServer(); + registerSearchTool(server, registry, client); + + const result = await server.call("harness_search", { + query: "takeda", + resource_types: ["gitops_application"], + org_id: "takeda-org", + project_id: "takeda-project", + }); + + expect(result.isError).toBeUndefined(); + const data = parseResult(result) as { total_matches: number; results: Array<{ resource_type: string; items: Array> }> }; + expect(data.total_matches).toBe(1); + expect(data.results[0]).toMatchObject({ + resource_type: "gitops_application", + }); + expect(String(data.results[0]?.items[0]?.name)).toContain("takeda-gitops-app"); + expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ + path: "/gitops/api/v1/applications", + body: expect.objectContaining({ + accountIdentifier: "test-account", + orgIdentifier: "takeda-org", + projectIdentifier: "takeda-project", + searchTerm: "takeda", + }), + })); + }); + it("gracefully handles search failures for individual types", async () => { // First call fails, second succeeds mockRequest