Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Policy = SCHEMA.Policy & {
versionUserApprovals: SCHEMA.PolicyRuleUserApproval[];
versionRoleApprovals: SCHEMA.PolicyRuleRoleApproval[];
concurrency: SCHEMA.PolicyRuleConcurrency | null;
environmentVersionRollout: SCHEMA.PolicyRuleEnvironmentVersionRollout | null;
};

type PolicyFormContextType = {
Expand All @@ -47,14 +48,22 @@ export const PolicyFormContextProvider: React.FC<{
policy: Policy;
}> = ({ children, policy }) => {
const concurrency = policy.concurrency?.concurrency ?? null;
const defaultValues = { ...policy, concurrency };
const environmentVersionRollout =
policy.environmentVersionRollout != null
? {
...policy.environmentVersionRollout,
rolloutType:
SCHEMA.dbRolloutTypeToAPIRolloutType[
policy.environmentVersionRollout.rolloutType
],
}
: null;
const defaultValues = { ...policy, concurrency, environmentVersionRollout };
const form = useForm({
schema: SCHEMA.updatePolicy,
defaultValues,
});

console.log(form.getValues());

const router = useRouter();
const utils = api.useUtils();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export const openapi: Swagger.SwaggerV3 = {
type: "object",
properties: {
reason: { type: "string" },
approvedAt: {
type: "string",
format: "date-time",
nullable: true,
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { request } from "~/app/api/v1/middleware";

const bodySchema = z.object({
reason: z.string().optional(),
approvedAt: z.string().datetime().optional(),
});
Comment on lines 14 to 17
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.

🛠️ Refactor suggestion

Validate & coerce approvedAt into a Date upfront

z.string().datetime() merely validates the ISO-8601 string but you still need to turn it into a Date.
Doing the coercion in the schema avoids repeated new Date() logic and guarantees you never store an Invalid Date.

-const bodySchema = z.object({
-  reason: z.string().optional(),
-  approvedAt: z.string().datetime().optional(),
-});
+const bodySchema = z.object({
+  reason: z.string().optional(),
+  approvedAt: z
+    .string()
+    .datetime({ offset: true })  // enforce timezone to avoid local-time ambiguities
+    .optional()
+    .transform((v) => (v ? new Date(v) : undefined)),
+});

The rest of the handler can then use ctx.body.approvedAt ?? new Date() without additional parsing.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const bodySchema = z.object({
reason: z.string().optional(),
approvedAt: z.string().datetime().optional(),
});
const bodySchema = z.object({
reason: z.string().optional(),
approvedAt: z
.string()
.datetime({ offset: true }) // enforce timezone to avoid local-time ambiguities
.optional()
.transform((v) => (v ? new Date(v) : undefined)),
});
🤖 Prompt for AI Agents
In
apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/approve/route.ts
around lines 14 to 17, the schema currently validates approvedAt as an ISO-8601
string but does not convert it to a Date object. Update the zod schema to coerce
approvedAt into a Date using z.coerce.date().optional() instead of
z.string().datetime().optional(). This ensures approvedAt is always a valid Date
object, allowing the rest of the handler to use ctx.body.approvedAt ?? new
Date() without extra parsing or risk of invalid dates.


export const POST = request()
Expand Down Expand Up @@ -42,14 +43,16 @@ export const POST = request()
{ status: NOT_FOUND },
);

const approvedAt =
ctx.body.approvedAt != null ? new Date(ctx.body.approvedAt) : new Date();
const record = await ctx.db
.insert(schema.policyRuleAnyApprovalRecord)
.values({
deploymentVersionId,
userId: ctx.user.id,
status: schema.ApprovalStatus.Approved,
reason: ctx.body.reason,
approvedAt: new Date(),
approvedAt,
})
.onConflictDoNothing()
.returning();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { Swagger } from "atlassian-openapi";

export const openapi: Swagger.SwaggerV3 = {
openapi: "3.0.0",
info: { title: "Ctrlplane API", version: "1.0.0" },
paths: {
"/v1/deployment-versions/{deploymentVersionId}/environments/{environmentId}/rollout":
{
get: {
summary:
"Get the rollout information across all release targets for a given deployment version and environment",
operationId: "getRolloutInfo",
parameters: [
{
name: "deploymentVersionId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "The deployment version ID",
},
{
name: "environmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "The environment ID",
},
],
responses: {
200: {
description: "The rollout information",
content: {
"application/json": {
schema: {
type: "array",
items: {
allOf: [
{ $ref: "#/components/schemas/ReleaseTarget" },
{
type: "object",
properties: {
rolloutTime: {
type: "string",
format: "date-time",
nullable: true,
},
rolloutPosition: { type: "number" },
},
required: ["rolloutTime", "rolloutPosition"],
},
],
},
},
},
},
},
404: {
description:
"The deployment version or environment was not found",
content: {
"application/json": {
schema: {
type: "object",
properties: { error: { type: "string" } },
},
},
},
},
500: {
description: "Internal server error",
content: {
"application/json": {
schema: {
type: "object",
properties: { error: { type: "string" } },
},
},
},
},
},
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { Tx } from "@ctrlplane/db";
import { NextResponse } from "next/server";
import { INTERNAL_SERVER_ERROR, NOT_FOUND } from "http-status";

import { and, eq, takeFirstOrNull } from "@ctrlplane/db";
import * as schema from "@ctrlplane/db/schema";
import { logger } from "@ctrlplane/logger";
import {
getRolloutInfoForReleaseTarget,
mergePolicies,
} from "@ctrlplane/rule-engine";
import { getApplicablePoliciesWithoutResourceScope } from "@ctrlplane/rule-engine/db";
import { Permission } from "@ctrlplane/validators/auth";

import { authn, authz } from "~/app/api/v1/auth";
import { request } from "~/app/api/v1/middleware";

const log = logger.child({
route:
"/v1/deployment-versions/{deploymentVersionId}/environments/{environmentId}/rollout",
});

const getDeploymentVersion = async (db: Tx, deploymentVersionId: string) =>
db
.select()
.from(schema.deploymentVersion)
.where(eq(schema.deploymentVersion.id, deploymentVersionId))
.then(takeFirstOrNull);

const getEnvironment = async (db: Tx, environmentId: string) =>
db
.select()
.from(schema.environment)
.where(eq(schema.environment.id, environmentId))
.then(takeFirstOrNull);

const getReleaseTargets = async (
db: Tx,
deploymentId: string,
environmentId: string,
) =>
db
.select()
.from(schema.releaseTarget)
.innerJoin(
schema.deployment,
eq(schema.releaseTarget.deploymentId, schema.deployment.id),
)
.innerJoin(
schema.environment,
eq(schema.releaseTarget.environmentId, schema.environment.id),
)
.innerJoin(
schema.resource,
eq(schema.releaseTarget.resourceId, schema.resource.id),
)
.where(
and(
eq(schema.releaseTarget.deploymentId, deploymentId),
eq(schema.releaseTarget.environmentId, environmentId),
),
)
.then((rows) =>
rows.map((row) => ({
...row.release_target,
deployment: row.deployment,
environment: row.environment,
resource: row.resource,
})),
);

export const GET = request()
.use(authn)
.use(
authz(({ can, params }) =>
can.perform(Permission.DeploymentVersionGet).on({
type: "deploymentVersion",
id: params.deploymentVersionId ?? "",
}),
),
)
.handle<
{ db: Tx },
{ params: Promise<{ deploymentVersionId: string; environmentId: string }> }
>(async ({ db }, { params }) => {
try {
const { deploymentVersionId, environmentId } = await params;

const deploymentVersion = await getDeploymentVersion(
db,
deploymentVersionId,
);
if (deploymentVersion == null)
return NextResponse.json(
{ error: "Deployment version not found" },
{ status: NOT_FOUND },
);

const environment = await getEnvironment(db, environmentId);
if (environment == null)
return NextResponse.json(
{ error: "Environment not found" },
{ status: NOT_FOUND },
);

const releaseTargets = await getReleaseTargets(
db,
deploymentVersion.deploymentId,
environmentId,
);

const policies = await getApplicablePoliciesWithoutResourceScope(
db,
environmentId,
deploymentVersion.deploymentId,
);
const policy = mergePolicies(policies);

const releaseTargetsWithRolloutInfo = await Promise.all(
releaseTargets.map((releaseTarget) =>
getRolloutInfoForReleaseTarget(
db,
releaseTarget,
policy,
deploymentVersion,
),
),
);

const releaseTargetsSortedByRolloutPosition =
releaseTargetsWithRolloutInfo.sort(
(a, b) => a.rolloutPosition - b.rolloutPosition,
);

return NextResponse.json(releaseTargetsSortedByRolloutPosition);
} catch (error) {
log.error("Error getting rollout info", { error });
return NextResponse.json(
{ error: "Internal server error" },
{ status: INTERNAL_SERVER_ERROR },
);
}
});
65 changes: 0 additions & 65 deletions apps/webservice/src/app/api/v1/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,71 +145,6 @@ export const openapi: Swagger.SwaggerV3 = {
"jobAgentConfig",
],
},
Policy: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "The policy ID" },
systemId: {
type: "string",
format: "uuid",
description: "The system ID",
},
name: { type: "string", description: "The name of the policy" },
description: {
type: "string",
nullable: true,
description: "The description of the policy",
},
approvalRequirement: {
type: "string",
enum: ["manual", "automatic"],
description: "The approval requirement of the policy",
},
successType: {
type: "string",
enum: ["some", "all", "optional"],
description:
"If a policy depends on an environment, whether or not the policy requires all, some, or optional successful releases in the environment",
},
successMinimum: {
type: "number",
description:
"If a policy depends on an environment, the minimum number of successful releases in the environment",
},
concurrencyLimit: {
type: "number",
nullable: true,
description:
"The maximum number of concurrent releases in the environment",
},
rolloutDuration: {
type: "number",
description: "The duration of the rollout in milliseconds",
},
minimumReleaseInterval: {
type: "number",
description:
"The minimum interval between releases in milliseconds",
},
releaseSequencing: {
type: "string",
enum: ["wait", "cancel"],
description:
"If a new release is created, whether it will wait for the current release to finish before starting, or cancel the current release",
},
},
required: [
"id",
"systemId",
"name",
"approvalRequirement",
"successType",
"successMinimum",
"rolloutDuration",
"minimumReleaseInterval",
"releaseSequencing",
],
},
Environment: {
type: "object",
properties: {
Expand Down
Loading
Loading