Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
85 changes: 85 additions & 0 deletions packages/control-plane/src/automation/automation-mutation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
AutomationMutationInputError,
parseCreateAutomationMutation,
parseUpdateAutomationMutation,
} from "./automation-mutation";

describe("automation mutation ingress", () => {
it("parses and normalizes a create request with canonical nested schemas", () => {
const input = parseCreateAutomationMutation({
name: "Daily review",
instructions: "Review open changes",
triggerType: "slack_event",
triggerConfig: {
conditions: [{ type: "slack_channel", operator: "any_of", value: ["C123"] }],
},
repositories: [{ repoOwner: " Acme ", repoName: " API ", baseBranch: null }],
providerSelections: { openai: { mode: "provider_account", accountId: "a".repeat(32) } },
actorDisplayName: "Automation Bot",
});

expect(input).toMatchObject({
triggerType: "slack_event",
repositories: [{ repoOwner: "acme", repoName: "api", baseBranch: null }],
triggerConfig: {
conditions: [{ type: "slack_channel", operator: "any_of", value: ["C123"] }],
},
actorDisplayName: "Automation Bot",
});
});

it("rejects invalid trigger-specific shapes at ingress", () => {
expect(() =>
parseCreateAutomationMutation({
name: "Slack review",
instructions: "Review",
triggerType: "slack_event",
triggerConfig: { conditions: "C123" },
})
).toThrowError(new AutomationMutationInputError("triggerConfig.conditions must be an array"));
});

it("keeps create and update semantics distinct", () => {
expect(() => parseCreateAutomationMutation({ name: "Incomplete" })).toThrowError(
new AutomationMutationInputError("instructions is required")
);

expect(parseUpdateAutomationMutation({ name: "Renamed" })).toEqual({ name: "Renamed" });
});

it("preserves update-specific scalar error messages", () => {
expect(() => parseUpdateAutomationMutation({ name: 42 })).toThrowError(
new AutomationMutationInputError("name cannot be empty")
);
expect(() => parseUpdateAutomationMutation({ instructions: null })).toThrowError(
new AutomationMutationInputError("instructions cannot be empty")
);
});

it("preserves omitted, cleared, and replaced update fields", () => {
const omitted = parseUpdateAutomationMutation({ name: "Renamed" });
expect("triggerConfig" in omitted).toBe(false);
expect("reasoningEffort" in omitted).toBe(false);
expect("repositories" in omitted).toBe(false);
expect("environmentIds" in omitted).toBe(false);
expect("providerSelections" in omitted).toBe(false);

const cleared = parseUpdateAutomationMutation({
triggerConfig: null,
eventType: null,
reasoningEffort: null,
repositories: [],
environmentIds: [],
providerSelections: {},
});
expect(cleared).toEqual({
triggerConfig: null,
eventType: null,
reasoningEffort: null,
repositories: [],
environmentIds: [],
providerSelections: {},
});
});
});
146 changes: 146 additions & 0 deletions packages/control-plane/src/automation/automation-mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { z } from "zod";
import {
automationRepositoriesInputSchema,
createAutomationRequestSchema,
updateAutomationRequestSchema,
} from "@open-inspect/shared/types/automations";
import { triggerConfigSchema } from "@open-inspect/shared/triggers";
import { modelProviderSelectionsSchema } from "@open-inspect/shared/types/provider-accounts";

const automationTriggerTypeSchema = createAutomationRequestSchema.shape.triggerType.unwrap();

const createAutomationMutationSchema = z
Comment thread
open-inspect[bot] marked this conversation as resolved.
Outdated
.object({
name: createAutomationRequestSchema.shape.name,
instructions: createAutomationRequestSchema.shape.instructions,
triggerType: automationTriggerTypeSchema.nullable().optional(),
scheduleCron: createAutomationRequestSchema.shape.scheduleCron.nullable(),
scheduleTz: createAutomationRequestSchema.shape.scheduleTz.nullable(),
model: createAutomationRequestSchema.shape.model.nullable(),
reasoningEffort: createAutomationRequestSchema.shape.reasoningEffort,
eventType: createAutomationRequestSchema.shape.eventType.nullable(),
triggerConfig: triggerConfigSchema.optional(),
sentryClientSecret: createAutomationRequestSchema.shape.sentryClientSecret,
repositories: automationRepositoriesInputSchema.optional(),
environmentIds: z.array(z.string()).optional(),
providerSelections: modelProviderSelectionsSchema.nullable().optional(),
actorDisplayName: z.string().optional(),
actorEmail: z.string().optional(),
actorAvatarUrl: z.string().optional(),
})
.transform(({ triggerType, providerSelections, ...input }) => ({
...input,
...(triggerType ? { triggerType } : {}),
...(providerSelections ? { providerSelections } : {}),
}));

const updateAutomationMutationSchema = z.object({
name: updateAutomationRequestSchema.shape.name,
instructions: updateAutomationRequestSchema.shape.instructions,
scheduleCron: updateAutomationRequestSchema.shape.scheduleCron,
scheduleTz: updateAutomationRequestSchema.shape.scheduleTz,
model: updateAutomationRequestSchema.shape.model,
reasoningEffort: updateAutomationRequestSchema.shape.reasoningEffort,
eventType: z.string().nullable().optional(),
triggerConfig: triggerConfigSchema.nullable().optional(),
repositories: automationRepositoriesInputSchema.optional(),
environmentIds: z.array(z.string()).optional(),
providerSelections: modelProviderSelectionsSchema.optional(),
});

export type CreateAutomationMutation = z.output<typeof createAutomationMutationSchema>;
export type UpdateAutomationMutation = z.output<typeof updateAutomationMutationSchema>;

export class AutomationMutationInputError extends Error {
constructor(message: string) {
super(message);
this.name = "AutomationMutationInputError";
}
}

function formatTriggerConfigIssue(value: unknown, issue: z.core.$ZodIssue): string {
if (issue.path.length === 1 && issue.path[0] === "conditions") {
return "triggerConfig.conditions must be an array";
}

const path = ["triggerConfig", ...issue.path].map(String).join(".");
const conditionIndex = issue.path[0] === "conditions" ? issue.path[1] : undefined;
const rawConditions =
typeof value === "object" && value !== null && "conditions" in value
? (value as { conditions?: unknown }).conditions
: undefined;
const rawCondition =
typeof conditionIndex === "number" && Array.isArray(rawConditions)
? rawConditions[conditionIndex]
: undefined;
const conditionType =
typeof rawCondition === "object" &&
rawCondition !== null &&
"type" in rawCondition &&
typeof rawCondition.type === "string"
? `${rawCondition.type}: `
: "";
return `${path}: ${conditionType}${issue.message}`;
}

function formatMutationIssue(
value: unknown,
issue: z.core.$ZodIssue,
operation: "create" | "update"
): string {
const field = issue.path[0];
if (field === "name") return operation === "create" ? "name is required" : "name cannot be empty";
if (field === "instructions") {
return operation === "create" ? "instructions is required" : "instructions cannot be empty";
}
if (field === "triggerType") {
return `triggerType must be one of: ${automationTriggerTypeSchema.options.join(", ")}`;
}
if (field === "triggerConfig") {
const triggerConfig =
typeof value === "object" && value !== null && "triggerConfig" in value
? (value as { triggerConfig?: unknown }).triggerConfig
: undefined;
return formatTriggerConfigIssue(triggerConfig, { ...issue, path: issue.path.slice(1) });
}
if (field === "repositories") {
const index = issue.path[1];
return `repositories${typeof index === "number" ? `[${index}]` : ""}: ${issue.message}`;
}
if (field === "environmentIds") {
return "environmentIds must be an array of environment ids (env_…)";
}
if (field === "providerSelections") {
return ["providerSelections", ...issue.path.slice(1)].join(".") + `: ${issue.message}`;
}
return issue.message;
}

function parseMutation<T>(schema: z.ZodType<T>, value: unknown, operation: "create" | "update"): T {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
const issue = parsed.error.issues[0];
throw new AutomationMutationInputError(
issue ? formatMutationIssue(value, issue, operation) : "Invalid automation request"
);
}

export function parseCreateAutomationMutation(value: unknown): CreateAutomationMutation {
return parseMutation(createAutomationMutationSchema, value, "create");
}

export function parseUpdateAutomationMutation(
value: unknown,
currentTriggerType?: string
): UpdateAutomationMutation {
if (
currentTriggerType === "schedule" &&
typeof value === "object" &&
value !== null &&
"triggerConfig" in value &&
value.triggerConfig !== undefined
) {
throw new AutomationMutationInputError("Cannot set triggerConfig on schedule automations");
}
return parseMutation(updateAutomationMutationSchema, value, "update");
}
Loading
Loading