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
217 changes: 0 additions & 217 deletions src/app/api/v2/builds/[uuid]/route.test.ts

This file was deleted.

127 changes: 96 additions & 31 deletions src/app/api/v2/builds/[uuid]/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,84 @@
import { nanoid } from 'nanoid';
import { NextRequest } from 'next/server';
import { createApiHandler } from 'server/lib/createApiHandler';
import { getLogger, LogStage } from 'server/lib/logger';
import { errorResponse, successResponse } from 'server/lib/response';
import BuildService from 'server/services/build';
import OverrideService, { BuildUuidValidationError } from 'server/services/override';
import OverrideService, { BuildUuidValidationError, type BuildConfigPatchInput } from 'server/services/override';

interface UpdateBuildUuidRequest {
interface UpdateBuildConfigPatchRequest {
uuid?: unknown;
isStatic?: unknown;
trackDefaultBranches?: unknown;
commentRuntimeEnv?: unknown;
commentInitEnv?: unknown;
}

const BUILD_CONFIG_PATCH_FIELDS = ['uuid', 'isStatic', 'trackDefaultBranches', 'commentRuntimeEnv', 'commentInitEnv'];

function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}

function isPlainObject(value: unknown): value is Record<string, any> {
return isRecord(value);
}

function hasOwn(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}

function validateBuildConfigPatch(body: unknown): BuildConfigPatchInput | Error {
if (!isRecord(body)) {
return new Error('request body must be an object');
}

const unknownFields = Object.keys(body).filter((key) => !BUILD_CONFIG_PATCH_FIELDS.includes(key));
if (unknownFields.length > 0) {
return new Error(`Unsupported field(s): ${unknownFields.join(', ')}`);
}

if (!BUILD_CONFIG_PATCH_FIELDS.some((field) => hasOwn(body, field))) {
return new Error('At least one build config field is required');
}

const patch: BuildConfigPatchInput = {};

if (hasOwn(body, 'uuid')) {
if (typeof body.uuid !== 'string' || body.uuid.length === 0) {
return new Error('uuid must be a non-empty string');
}
patch.uuid = body.uuid;
}

if (hasOwn(body, 'isStatic')) {
if (typeof body.isStatic !== 'boolean') {
return new Error('isStatic must be a boolean');
}
patch.isStatic = body.isStatic;
}

if (hasOwn(body, 'trackDefaultBranches')) {
if (typeof body.trackDefaultBranches !== 'boolean') {
return new Error('trackDefaultBranches must be a boolean');
}
patch.trackDefaultBranches = body.trackDefaultBranches;
}

if (hasOwn(body, 'commentRuntimeEnv')) {
if (!isPlainObject(body.commentRuntimeEnv)) {
return new Error('commentRuntimeEnv must be an object');
}
patch.commentRuntimeEnv = body.commentRuntimeEnv;
}

if (hasOwn(body, 'commentInitEnv')) {
if (!isPlainObject(body.commentInitEnv)) {
return new Error('commentInitEnv must be an object');
}
patch.commentInitEnv = body.commentInitEnv;
}

return patch;
}

/**
Expand Down Expand Up @@ -68,11 +139,11 @@ const getHandler = async (req: NextRequest, { params }: { params: { uuid: string
* @openapi
* /api/v2/builds/{uuid}:
* patch:
* summary: Update a build UUID
* description: Updates a build UUID and the related deployable and deploy UUID fields.
* summary: Update build config
* description: Patches build-table config such as UUID, static mode, default-branch tracking, and comment environment overrides.
* tags:
* - Builds
* operationId: updateBuildUUID
* operationId: updateBuildConfig
* parameters:
* - in: path
* name: uuid
Expand All @@ -85,16 +156,16 @@ const getHandler = async (req: NextRequest, { params }: { params: { uuid: string
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateBuildUUIDRequest'
* $ref: '#/components/schemas/UpdateBuildConfigPatchRequest'
* responses:
* '200':
* description: Updated build object.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateBuildUUIDSuccessResponse'
* $ref: '#/components/schemas/UpdateBuildConfigSuccessResponse'
* '400':
* description: Invalid or unavailable UUID.
* description: Invalid request body or unavailable UUID.
* content:
* application/json:
* schema:
Expand All @@ -113,42 +184,36 @@ const getHandler = async (req: NextRequest, { params }: { params: { uuid: string
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const patchHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => {
const body = (await req.json().catch(() => null)) as UpdateBuildUuidRequest | null;
const newUuid = body?.uuid;
const body = (await req.json().catch(() => null)) as UpdateBuildConfigPatchRequest | null;
const patch = validateBuildConfigPatch(body);

if (!newUuid || typeof newUuid !== 'string') {
return errorResponse(new Error('uuid is required'), { status: 400 }, req);
if (patch instanceof Error) {
return errorResponse(patch, { status: 400 }, req);
}

const override = new OverrideService();
const buildService = new BuildService();
const build = await override.db.models.Build.query().findOne({ uuid: params.uuid }).withGraphFetched('pullRequest');

if (!build) {
return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req);
}

if (newUuid === build.uuid) {
return errorResponse(new Error('UUID must be different'), { status: 400 }, req);
}
try {
const updatedBuild = await override.applyBuildConfigPatch({
build,
pullRequest: build.pullRequest,
patch,
runUuid: nanoid(),
});

const validation = await override.validateUuid(newUuid, build.id);
if (!validation.valid) {
return errorResponse(new Error(validation.error || 'Invalid UUID'), { status: 400 }, req);
}
const hydratedBuild = await buildService.getBuildByUUID(updatedBuild.uuid);

try {
const result = await override.updateBuildUuid(build, newUuid);

if (build.pullRequest?.deployOnUpdate) {
getLogger({ stage: LogStage.BUILD_QUEUED }).info('Triggering redeploy after UUID update');
await new BuildService().resolveAndDeployBuildQueue.add('resolve-deploy', {
buildId: build.id,
runUUID: nanoid(),
correlationId: req.headers.get('x-request-id') || `api-build-update-${Date.now()}`,
});
if (!hydratedBuild) {
return errorResponse(new Error(`Build with UUID ${updatedBuild.uuid} not found`), { status: 404 }, req);
}

return successResponse(result.build, { status: 200 }, req);
return successResponse(hydratedBuild, { status: 200 }, req);
} catch (error) {
if (error instanceof BuildUuidValidationError) {
return errorResponse(error, { status: 400 }, req);
Expand Down
Loading
Loading