Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1507,10 +1507,10 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an
- **`fme_environment`** — `list` wired to the real endpoint.
- **`fme_feature_flag`** — dual-mode, both branches fully wired. Harness-native (`org_id`+`project_id`): `list`/`get`/`create`/`delete` hit `/fme/api/v4/feature-flags` (body for `create`: `name`, `trafficType`, optional `description`/`tags`/`owners`, per `CreateFeatureFlagRequest`); `update` sends a merge-patch to `/fme/api/v4/feature-flags/{name}`; `archive`/`unarchive` hit `/fme/api/v4/feature-flags/{name}/archive|unarchive` (optional `comment` only — no `title`, per `ArchiveUnarchiveRequest`); `kill`/`restore`/`reallocate` hit `/fme/api/v4/feature-flag-definitions/{name}/kill|restore|reallocate` with `environment_id` as a query param (optional `comment`/`title`, per `FeatureFlagDefinitionActionRequest`).
- **`fme_feature_flag_definition`** — `get`/`create`/`update` are wired to the real `/fme/api/v4/feature-flag-definitions` endpoint. Body shape is identical to legacy mode (`treatments`, `defaultTreatment`, `defaultRule`, optional `rules`/`baselineTreatment`/`trafficAllocation`/`comment`), plus an optional `title` field available only in Harness-native mode. `environment_id` is passed as a query param (not a path segment, unlike legacy mode).
- **`fme_rollout_status`** — `list` is not yet implemented.
- **`fme_rollout_status`** — dual-mode `list`. Legacy `workspace_id` still hits Split Admin `/internal/api/v2/rolloutStatuses/ws/{wsId}`. Harness-native (`org_id`+`project_id`) hits `GET /fme/api/v4/rollout-statuses` with optional `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`). Envelope `{data, limit, offset, totalCount}` is promoted to `items`/`total`. Items `{type: "ROLLOUT_STATUS", id, name, description?}`. List-only.
- **`fme_rule_based_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`/`create`/`delete`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract.
- **`fme_rule_based_segment_definition`** — (Deprecated — see `fme_segment_definition`.) Harness-native mode is rejected on every operation/action (`list`/`update`/`enable`/`disable`/`change_request`) — use `fme_segment_definition` instead (no `enable`/`disable`/`change_request` equivalent there); this resource supports only the legacy `workspace_id`/`environment_id` contract.
- **`fme_traffic_type`** — `list` is not yet implemented.
- **`fme_traffic_type`** — dual-mode `list`. Legacy `workspace_id` still hits Split Admin `/internal/api/v2/trafficTypes/ws/{wsId}`. Harness-native (`org_id`+`project_id`) hits `GET /fme/api/v4/traffic-types` with optional `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`). Envelope `{data, limit, offset, totalCount}` is promoted to `items`/`total`. Items `{type: "TRAFFIC_TYPE", id, name}` (no `displayAttributeId`). List-only.
- **`fme_identity`** — `create`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call.
- **`fme_standard_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. There is no `create` operation for this resource in either mode.
- **`fme_segment_keys`** — `list`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call.
Expand Down
13 changes: 13 additions & 0 deletions src/registry/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,19 @@ export function flattenTrafficType(item: Record<string, unknown>): void {
}
}

/**
* Public v4 paginated lists (`TrafficTypeListResponse` / `RolloutStatusListResponse`):
* `{ data, limit, offset, totalCount }`. Promote `data`→`items` and `totalCount`→`total`
* so harness_list compact/output schema see a full total, not the current page length.
*/
export const fmeV4PaginatedListExtract = (raw: unknown): unknown => {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.data)) return raw;
const total = typeof r.totalCount === "number" ? r.totalCount : r.data.length;
return { ...r, items: r.data, total };
};

/** Extract FME feature flag list — passthrough with trafficType.id flattened on each item. */
export const fmeListExtract = (raw: unknown): unknown => {
if (raw && typeof raw === "object") {
Expand Down
42 changes: 21 additions & 21 deletions src/registry/toolsets/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ToolsetDefinition, BodySchema } from "../types.js";
import { passthrough, fmeListExtract, fmeGetExtract } from "../extractors.js";
import { passthrough, fmeListExtract, fmeGetExtract, fmeV4PaginatedListExtract } from "../extractors.js";
import { isFmeHarnessNativeSelected, logFmeDeprecation, requireFmeIdentifier, requireHarnessNativeSegmentScope, resolveFmeDualMode } from "../scope-utils.js";

const fmeActionExtract = (raw: unknown) => {
Expand Down Expand Up @@ -680,32 +680,32 @@ export const featureFlagsToolset: ToolsetDefinition = {
resourceType: "fme_rollout_status",
displayName: "FME Rollout Status",
description:
"Rollout status definitions for a workspace (e.g. Killed, Permanent, Ramping). Use to discover valid rollout_status_id UUIDs for filtering fme_feature_flag lists. Note: this endpoint may not be available on all account types — rollout status IDs are also returned inline with fme_feature_flag list results.",
"Rollout status definitions (e.g. Killed, Permanent, Ramping). Dual-mode list: org_id+project_id (Harness-native GET /fme/api/v4/rollout-statuses) or deprecated workspace_id (Split Admin). Use to discover rollout_status_id UUIDs for filtering fme_feature_flag lists. Native response is {data, limit, offset, totalCount}; each item is {type: \"ROLLOUT_STATUS\", id, name, description?}. List-only — no get/create/update/delete.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid exposing the details of endpoint in the description.

toolset: "feature-flags",
scope: "account",
scopeOptional: true,
identifierFields: ["workspace_id"],
product: "fme",
listFilterFields: [
{ name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." },
{ name: "offset", description: "Harness-native pagination offset (default 0)", type: "number" },
{ name: "limit", description: "Harness-native page size (default 100, max 100)", type: "number" },
],
operations: {
list: {
method: "GET",
path: "/internal/api/v2/rolloutStatuses/ws/{wsId}",
path: "",
routeResolver: (input) => {
const mode = resolveFmeDualMode(input, "fme_rollout_status");
if (mode.mode === "harness_native") {
throw new Error(
"fme_rollout_status.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.",
);
if (mode.mode === "legacy") {
return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` };
}
return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` };
return { path: "/fme/api/v4/rollout-statuses", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS };
},
operationPolicy: { risk: "read", retryPolicy: "safe" },
pathParams: { workspace_id: "wsId" },
responseExtractor: passthrough,
description: "List rollout status definitions for a workspace (Killed, Permanent, Ramping, etc.). If this returns 404, use rolloutStatus fields from fme_feature_flag list results instead.",
queryParams: { offset: "offset", size: "limit", limit: "limit" },
responseExtractor: fmeV4PaginatedListExtract,
description: "List rollout statuses. Legacy: workspace_id → Split Admin. Harness-native: org_id+project_id, optional offset/limit (harness_list size maps to limit). Envelope {data, limit, offset, totalCount} promoted to items/total.",
},
},
},
Expand Down Expand Up @@ -931,32 +931,32 @@ export const featureFlagsToolset: ToolsetDefinition = {
resourceType: "fme_traffic_type",
displayName: "FME Traffic Type",
description:
"Traffic type in a workspace (e.g. 'user', 'account'). List traffic types to discover traffic_type_id values needed for identity queries and flag/segment creation.",
"Traffic type (e.g. 'user', 'account'). Dual-mode list: org_id+project_id (Harness-native GET /fme/api/v4/traffic-types) or deprecated workspace_id (Split Admin). Use to discover traffic_type_id / name values for flag and segment create. Native items are {type: \"TRAFFIC_TYPE\", id, name} — no displayAttributeId. List-only — no get/create/update/delete.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here let's avoid exposing the details of the underlying end points. Provide details of tool.

Also it is clear that is listing and has not GET/CREATE etc

toolset: "feature-flags",
scope: "account",
scopeOptional: true,
identifierFields: ["workspace_id"],
product: "fme",
listFilterFields: [
{ name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." },
{ name: "offset", description: "Harness-native pagination offset (default 0)", type: "number" },
{ name: "limit", description: "Harness-native page size (default 100, max 100)", type: "number" },
],
operations: {
list: {
method: "GET",
path: "/internal/api/v2/trafficTypes/ws/{wsId}",
path: "",
routeResolver: (input) => {
const mode = resolveFmeDualMode(input, "fme_traffic_type");
if (mode.mode === "harness_native") {
throw new Error(
"fme_traffic_type.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.",
);
if (mode.mode === "legacy") {
return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` };
}
return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` };
return { path: "/fme/api/v4/traffic-types", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS };
},
operationPolicy: { risk: "read", retryPolicy: "safe" },
pathParams: { workspace_id: "wsId" },
responseExtractor: passthrough,
description: "List traffic types for a workspace. Returns id, name, and displayAttributeId for each traffic type.",
queryParams: { offset: "offset", size: "limit", limit: "limit" },
responseExtractor: fmeV4PaginatedListExtract,
description: "List traffic types. Legacy: workspace_id → Split Admin (may include displayAttributeId). Harness-native: org_id+project_id, optional offset/limit (harness_list size maps to limit); envelope {data, limit, offset, totalCount} with id and name only, promoted to items/total.",
},
},
},
Expand Down
138 changes: 119 additions & 19 deletions tests/registry/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,125 @@ describe("fme_environment dual-mode routing", () => {
});
});

describe("fme_traffic_type and fme_rollout_status dual-mode list", () => {
let registry: Registry;

beforeEach(() => {
registry = new Registry(makeConfig());
});

it.each([
["fme_traffic_type", "/fme/api/v4/traffic-types", "/internal/api/v2/trafficTypes/ws/ws1"],
["fme_rollout_status", "/fme/api/v4/rollout-statuses", "/internal/api/v2/rolloutStatuses/ws/ws1"],
] as const)("%s: native list uses v4 path and FME scope params", async (resourceType, nativePath, _legacyPath) => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", { org_id: "o1", project_id: "p1" });

const req = firstRequest(mockRequest);
expect(req.path).toBe(nativePath);
expect(req.product).toBeUndefined();
expect(req.params).toMatchObject({
account_id: "test-account",
organization_identifier: "o1",
project_identifier: "p1",
});
expect(req.params?.orgIdentifier).toBeUndefined();
expect(req.params?.projectIdentifier).toBeUndefined();
});

it.each([
["fme_traffic_type", "/internal/api/v2/trafficTypes/ws/ws1"],
["fme_rollout_status", "/internal/api/v2/rolloutStatuses/ws/ws1"],
] as const)("%s: legacy list keeps Split Admin path", async (resourceType, legacyPath) => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", { workspace_id: "ws1" });

const req = firstRequest(mockRequest);
expect(req.path).toBe(legacyPath);
expect(req.product).toBe("fme");
});

it("native list forwards offset and limit as query params", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "fme_traffic_type", "list", {
org_id: "o1",
project_id: "p1",
offset: 10,
limit: 25,
});

expect(firstRequest(mockRequest).params).toMatchObject({ offset: 10, limit: 25 });
});

it("native list maps harness_list size onto Java limit", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "fme_rollout_status", "list", {
org_id: "o1",
project_id: "p1",
size: 20,
});

expect(firstRequest(mockRequest).params).toMatchObject({ limit: 20 });
});

it("native list prefers explicit limit over size", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "fme_traffic_type", "list", {
org_id: "o1",
project_id: "p1",
size: 20,
limit: 5,
});

expect(firstRequest(mockRequest).params?.limit).toBe(5);
});

it("native list promotes totalCount to total and data to items", async () => {
const mockRequest = vi.fn().mockResolvedValue({
data: [{ type: "TRAFFIC_TYPE", id: "tt1", name: "user" }],
limit: 100,
offset: 0,
totalCount: 3,
});
const client = makeClient(mockRequest);

const result = await registry.dispatch(client, "fme_traffic_type", "list", {
org_id: "o1",
project_id: "p1",
});

expect(result).toMatchObject({
items: [{ type: "TRAFFIC_TYPE", id: "tt1", name: "user" }],
total: 3,
totalCount: 3,
});
});

it("rejects mixed workspace_id and org/project", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await expect(
registry.dispatch(client, "fme_rollout_status", "list", {
workspace_id: "ws1",
org_id: "o1",
project_id: "p1",
}),
).rejects.toThrow("fme_rollout_status: pass either workspace_id (deprecated) OR org_id+project_id, not both.");
expect(mockRequest).not.toHaveBeenCalled();
});
});

describe("fme_standard_segment — legacy only, Harness-native rejected in favor of fme_segment", () => {
let registry: Registry;

Expand Down Expand Up @@ -1091,25 +1210,6 @@ describe("FME new-mode (NYI) resources", () => {
registry = new Registry(makeConfig());
});

it.each([
["fme_rollout_status", "list", { workspace_id: "ws1" }],
["fme_traffic_type", "list", { workspace_id: "ws1" }],
] as [string, "get" | "create" | "update" | "list", Record<string, unknown>][])(
"%s.%s: legacy mode still works, new mode throws not-yet-implemented",
async (resourceType, operation, legacyInput) => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, operation, legacyInput);
expect(mockRequest).toHaveBeenCalledTimes(1);

const newModeInput = { ...legacyInput, workspace_id: undefined, org_id: "o1", project_id: "p1" };
await expect(registry.dispatch(client, resourceType, operation, newModeInput)).rejects.toThrow(
/not yet implemented/i,
);
},
);

it.each([
["list", { workspace_id: "ws1", environment_id: "e1" }],
["update", { workspace_id: "ws1", segment_name: "seg1", environment_id: "e1", body: {} }],
Expand Down