Skip to content
Closed
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
42 changes: 42 additions & 0 deletions src/registry/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,48 @@ export const chaosInputSetListExtract = (
return { items, total: page.total };
};

/**
* For child resources listed under a parent scope (e.g. triggers, input sets under a pipeline),
* list rows often lack the parent id field needed for deep links. Inject from item alternate
* API fields or the required list filter input so openInHarness does not fall back to
* item.identifier (the child id).
*/
export function parentScopedListExtract(
parentField: string,
itemAlternateFields: string[] = [],
): (raw: unknown, input?: Record<string, unknown>) => { items: unknown[]; total: number } {
return (raw, input) => {
const page = pageExtract(raw);
const fromInput = input?.[parentField];
const inputParentId = typeof fromInput === "string" && fromInput !== "" ? fromInput : undefined;
const items = page.items.map((item) => {
if (item && typeof item === "object" && !Array.isArray(item)) {
const rec = item as Record<string, unknown>;
if (rec[parentField] !== undefined && typeof rec[parentField] !== "object") {
return item;
}
let fromItem: string | undefined;
for (const alt of itemAlternateFields) {
const altValue = rec[alt];
if (typeof altValue === "string" && altValue !== "") {
fromItem = altValue;
break;
}
}
const resolved = fromItem ?? inputParentId;
if (resolved !== undefined) {
return { ...rec, [parentField]: resolved };
}
}
return item;
});
return { items, total: page.total };
};
}

/** Pipeline-scoped child lists (triggers, input sets) use pipelineIdentifier on rows. */
export const pipelineScopedListExtract = parentScopedListExtract("pipeline_id", ["pipelineIdentifier"]);

/**
* Normalize chaos experiment variables response (RunTimeInputs shape):
* { experiment: [...] | null, tasks: { taskName: [...] } | null }
Expand Down
14 changes: 10 additions & 4 deletions src/registry/toolsets/pipelines.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ToolsetDefinition, BodySchema, ParamsSchema } from "../types.js";
import { ngExtract, pageExtract, passthrough, v1ListExtract, runtimeInputExtract, executionInputsExtract, dynamicExecutionExtract } from "../extractors.js";
import { ngExtract, pageExtract, passthrough, v1ListExtract, runtimeInputExtract, executionInputsExtract, dynamicExecutionExtract, pipelineScopedListExtract } from "../extractors.js";
import YAML from "yaml";

function coerceTriggerBodyRecord(body: unknown): Record<string, unknown> {
Expand Down Expand Up @@ -743,7 +743,7 @@ export const pipelinesToolset: ToolsetDefinition = {
page: "page",
size: "size",
},
responseExtractor: pageExtract,
responseExtractor: pipelineScopedListExtract,
description: "List triggers for a pipeline",
},
get: {
Expand Down Expand Up @@ -801,8 +801,14 @@ export const pipelinesToolset: ToolsetDefinition = {
path: "/pipeline/api/triggers/{triggerIdentifier}",
operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" },
pathParams: { trigger_id: "triggerIdentifier" },
queryParams: { pipeline_id: "targetIdentifier" },
paramsSchema: {
fields: [
{ name: "pipeline_id", required: true, description: "Target pipeline identifier" },
],
} satisfies ParamsSchema,
responseExtractor: ngExtract,
description: "Delete a pipeline trigger",
description: "Delete a pipeline trigger. Requires pipeline_id (target pipeline) and trigger_id.",
},
},
},
Expand Down Expand Up @@ -846,7 +852,7 @@ export const pipelinesToolset: ToolsetDefinition = {
page: "page",
size: "size",
},
responseExtractor: pageExtract,
responseExtractor: pipelineScopedListExtract,
description: "List input sets for a pipeline",
},
get: {
Expand Down
26 changes: 26 additions & 0 deletions tests/coding-standards/registry-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { Registry } from "../../src/registry/index.js";
import { pageExtract } from "../../src/registry/extractors.js";

const REPO_ROOT = join(import.meta.dirname, "../..");

Expand Down Expand Up @@ -125,6 +126,31 @@ describe("Coding standards — registry metadata", () => {

expect(violations, violations.join("\n")).toEqual([]);
});

it("child list resources with parent deep-link placeholder do not use bare pageExtract", () => {
const violations: string[] = [];

for (const resourceType of registry.getAllResourceTypes()) {
const def = registry.getResource(resourceType);
const listSpec = def.operations.list;
if (!listSpec || !def.deepLinkTemplate) continue;
if (def.identifierFields.length < 2) continue;

const parentField = def.identifierFields[0]!;
if (!def.deepLinkTemplate.includes(`{${parentField}}`)) continue;

if (listSpec.responseExtractor === pageExtract) {
violations.push(
`${resourceType}: list uses pageExtract but deepLinkTemplate needs {${parentField}} — use parentScopedListExtract`,
);
}
}

expect(
violations,
`Parent-scoped list deep links will use child identifier:\n${violations.join("\n")}`,
).toEqual([]);
});
});

describe("Coding standards — write handler safety", () => {
Expand Down
163 changes: 163 additions & 0 deletions tests/registry/parent-scoped-list-deep-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Parent-scoped list deep links must keep the parent id from the request
* (via pipelineScopedListExtract) — not overwrite with item.identifier (the child id).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Registry } from "../../src/registry/index.js";
import type { Config } from "../../src/config.js";
import type { HarnessClient } from "../../src/client/harness-client.js";

function makeConfig(overrides: Partial<Config> = {}): Config {
return {
HARNESS_API_KEY: "pat.test",
HARNESS_ACCOUNT_ID: "test-account",
HARNESS_BASE_URL: "https://app.harness.io",
HARNESS_ORG: "default",
HARNESS_PROJECT: "test-project",
HARNESS_API_TIMEOUT_MS: 30000,
HARNESS_MAX_RETRIES: 3,
HARNESS_MAX_BODY_SIZE_MB: 10,
HARNESS_RATE_LIMIT_RPS: 10,
HARNESS_READ_ONLY: false,
HARNESS_SKIP_ELICITATION: false,
HARNESS_ALLOW_HTTP: false,
HARNESS_FME_BASE_URL: "https://api.split.io",
LOG_LEVEL: "info",
...overrides,
};
}

function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient {
return {
request: requestFn ?? vi.fn().mockResolvedValue({}),
account: "test-account",
} as unknown as HarnessClient;
}

describe("trigger list deep links", () => {
let registry: Registry;

beforeEach(() => {
registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "pipelines" }));
});

it("keeps request pipeline_id in openInHarness (not item.identifier)", async () => {
const mockRequest = vi.fn().mockResolvedValue({
data: {
content: [
{ identifier: "mcp_yaml_verify_cron", name: "mcp_yaml_verify_cron", type: "Scheduled" },
],
totalElements: 1,
},
});
const client = makeClient(mockRequest);

const result = (await registry.dispatch(client, "trigger", "list", {
org_id: "default",
project_id: "test-project",
pipeline_id: "test",
})) as { items: Array<Record<string, unknown>> };

const link = result.items[0]!.openInHarness as string;
expect(link).toContain("/pipelines/test/triggers");
expect(link).not.toContain("/pipelines/mcp_yaml_verify_cron/");
});

it("prefers item.pipelineIdentifier over request pipeline_id when present", async () => {
const mockRequest = vi.fn().mockResolvedValue({
data: {
content: [
{
identifier: "mcp_yaml_verify_cron",
name: "mcp_yaml_verify_cron",
pipelineIdentifier: "otherPipe",
},
],
totalElements: 1,
},
});
const client = makeClient(mockRequest);

const result = (await registry.dispatch(client, "trigger", "list", {
org_id: "default",
project_id: "test-project",
pipeline_id: "test",
})) as { items: Array<Record<string, unknown>> };

const link = result.items[0]!.openInHarness as string;
expect(link).toContain("/pipelines/otherPipe/triggers");
expect(link).not.toContain("/pipelines/test/");
expect(link).not.toContain("/pipelines/mcp_yaml_verify_cron/");
});
});

describe("trigger delete targetIdentifier", () => {
let registry: Registry;

beforeEach(() => {
registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "pipelines" }));
});

it("sends pipeline_id as targetIdentifier query param", async () => {
const mockRequest = vi.fn().mockResolvedValue({ status: "SUCCESS", data: true });
const client = makeClient(mockRequest);

await registry.dispatch(client, "trigger", "delete", {
org_id: "default",
project_id: "test-project",
trigger_id: "mcp_yaml_verify_cron",
pipeline_id: "test",
});

expect(mockRequest).toHaveBeenCalledOnce();
const call = mockRequest.mock.calls[0]![0] as {
method: string;
path: string;
params: Record<string, unknown>;
};
expect(call.method).toBe("DELETE");
expect(call.path).toBe("/pipeline/api/triggers/mcp_yaml_verify_cron");
expect(call.params.targetIdentifier).toBe("test");
});

it("throws when pipeline_id is missing", async () => {
const client = makeClient();
await expect(
registry.dispatch(client, "trigger", "delete", {
org_id: "default",
project_id: "test-project",
trigger_id: "mcp_yaml_verify_cron",
}),
).rejects.toThrow(/Missing required param\(s\) for trigger\.delete: pipeline_id/);
});
});

describe("input_set list deep links", () => {
let registry: Registry;

beforeEach(() => {
registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "pipelines" }));
});

it("keeps request pipeline_id in openInHarness (not item.identifier)", async () => {
const mockRequest = vi.fn().mockResolvedValue({
data: {
content: [
{ identifier: "my_input_set", name: "My Input Set" },
],
totalElements: 1,
},
});
const client = makeClient(mockRequest);

const result = (await registry.dispatch(client, "input_set", "list", {
org_id: "default",
project_id: "test-project",
pipeline_id: "parent_pipeline",
})) as { items: Array<Record<string, unknown>> };

const link = result.items[0]!.openInHarness as string;
expect(link).toContain("/pipelines/parent_pipeline/input-sets");
expect(link).not.toContain("/pipelines/my_input_set/");
});
});
Loading