From a1190437224743db06cf2fcb38e91e81c635c5b2 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Tue, 5 May 2026 17:12:44 -0700 Subject: [PATCH 1/3] feat: add v2 build override APIs --- src/app/api/v2/builds/[uuid]/route.test.ts | 217 ----- src/app/api/v2/builds/[uuid]/route.ts | 126 ++- .../api/v2/builds/[uuid]/services/route.ts | 164 ++++ .../services/__tests__/activityStream.test.ts | 96 +- .../services/__tests__/override.test.ts | 869 ++++++++++++++++++ src/server/services/activityStream.ts | 123 +-- src/server/services/override.ts | 329 ++++++- src/shared/openApiSpec.test.ts | 24 + src/shared/openApiSpec.ts | 103 ++- 9 files changed, 1649 insertions(+), 402 deletions(-) delete mode 100644 src/app/api/v2/builds/[uuid]/route.test.ts create mode 100644 src/app/api/v2/builds/[uuid]/services/route.ts create mode 100644 src/server/services/__tests__/override.test.ts diff --git a/src/app/api/v2/builds/[uuid]/route.test.ts b/src/app/api/v2/builds/[uuid]/route.test.ts deleted file mode 100644 index ec6a0c25..00000000 --- a/src/app/api/v2/builds/[uuid]/route.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Copyright 2026 Lifecycle contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextRequest } from 'next/server'; - -const mockGetBuildByUUID = jest.fn(); -const mockQueueAdd = jest.fn(); -const mockFindOne = jest.fn(); -const mockWithGraphFetched = jest.fn(); -const mockValidateUuid = jest.fn(); -const mockUpdateBuildUuid = jest.fn(); - -jest.mock('nanoid', () => ({ - nanoid: jest.fn(() => 'run-uuid'), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: jest.fn(() => ({ - error: jest.fn(), - info: jest.fn(), - })), - LogStage: { - BUILD_QUEUED: 'build_queued', - }, -})); - -jest.mock('server/services/build', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - getBuildByUUID: (...args: unknown[]) => mockGetBuildByUUID(...args), - resolveAndDeployBuildQueue: { - add: (...args: unknown[]) => mockQueueAdd(...args), - }, - })), -})); - -jest.mock('server/services/override', () => { - class BuildUuidValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'BuildUuidValidationError'; - } - } - - return { - __esModule: true, - BuildUuidValidationError, - default: jest.fn().mockImplementation(() => ({ - db: { - models: { - Build: { - query: jest.fn(() => ({ - findOne: (...args: unknown[]) => mockFindOne(...args), - })), - }, - }, - }, - validateUuid: (...args: unknown[]) => mockValidateUuid(...args), - updateBuildUuid: (...args: unknown[]) => mockUpdateBuildUuid(...args), - })), - }; -}); - -import { GET, PATCH } from './route'; - -function makeRequest(body?: Record): NextRequest { - return { - json: jest.fn().mockResolvedValue(body || {}), - headers: new Headers([['x-request-id', 'req-test']]), - nextUrl: new URL('http://localhost/api/v2/builds/current-build'), - } as unknown as NextRequest; -} - -describe('/api/v2/builds/[uuid]', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockFindOne.mockReturnValue({ - withGraphFetched: mockWithGraphFetched, - }); - mockValidateUuid.mockResolvedValue({ valid: true }); - mockUpdateBuildUuid.mockResolvedValue({ - build: { - id: 42, - uuid: 'new-build', - }, - deploysUpdated: 3, - }); - }); - - it('GET returns a build by UUID', async () => { - mockGetBuildByUUID.mockResolvedValueOnce({ - id: 42, - uuid: 'current-build', - }); - - const response = await GET(makeRequest(), { - params: { - uuid: 'current-build', - }, - }); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(mockGetBuildByUUID).toHaveBeenCalledWith('current-build'); - expect(body.data).toEqual({ - id: 42, - uuid: 'current-build', - }); - }); - - it('PATCH validates and updates the build UUID', async () => { - const build = { - id: 42, - uuid: 'current-build', - pullRequest: { - deployOnUpdate: true, - }, - }; - mockWithGraphFetched.mockResolvedValueOnce(build); - - const response = await PATCH(makeRequest({ uuid: 'new-build' }), { - params: { - uuid: 'current-build', - }, - }); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(mockFindOne).toHaveBeenCalledWith({ uuid: 'current-build' }); - expect(mockWithGraphFetched).toHaveBeenCalledWith('pullRequest'); - expect(mockValidateUuid).toHaveBeenCalledWith('new-build', 42); - expect(mockUpdateBuildUuid).toHaveBeenCalledWith(build, 'new-build'); - expect(mockQueueAdd).toHaveBeenCalledWith('resolve-deploy', { - buildId: 42, - runUUID: 'run-uuid', - correlationId: 'req-test', - }); - expect(body.data).toEqual({ - id: 42, - uuid: 'new-build', - }); - }); - - it('PATCH rejects unavailable UUIDs before updating', async () => { - mockWithGraphFetched.mockResolvedValueOnce({ - id: 42, - uuid: 'current-build', - }); - mockValidateUuid.mockResolvedValueOnce({ - valid: false, - error: 'UUID is not available', - }); - - const response = await PATCH(makeRequest({ uuid: 'existing-build' }), { - params: { - uuid: 'current-build', - }, - }); - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body.error.message).toBe('UUID is not available'); - expect(mockUpdateBuildUuid).not.toHaveBeenCalled(); - expect(mockQueueAdd).not.toHaveBeenCalled(); - }); - - it('PATCH returns 404 when the build does not exist', async () => { - mockWithGraphFetched.mockResolvedValueOnce(null); - - const response = await PATCH(makeRequest({ uuid: 'new-build' }), { - params: { - uuid: 'missing-build', - }, - }); - - expect(response.status).toBe(404); - expect(mockValidateUuid).not.toHaveBeenCalled(); - expect(mockUpdateBuildUuid).not.toHaveBeenCalled(); - }); - - it('PATCH rejects missing UUID bodies and no-op UUID changes', async () => { - const missingUuidResponse = await PATCH(makeRequest({}), { - params: { - uuid: 'current-build', - }, - }); - expect(missingUuidResponse.status).toBe(400); - - mockWithGraphFetched.mockResolvedValueOnce({ - id: 42, - uuid: 'current-build', - }); - - const sameUuidResponse = await PATCH(makeRequest({ uuid: 'current-build' }), { - params: { - uuid: 'current-build', - }, - }); - - expect(sameUuidResponse.status).toBe(400); - expect(mockValidateUuid).not.toHaveBeenCalled(); - expect(mockUpdateBuildUuid).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/v2/builds/[uuid]/route.ts b/src/app/api/v2/builds/[uuid]/route.ts index 6c34387d..dde757b5 100644 --- a/src/app/api/v2/builds/[uuid]/route.ts +++ b/src/app/api/v2/builds/[uuid]/route.ts @@ -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 { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function isPlainObject(value: unknown): value is Record { + return isRecord(value); +} + +function hasOwn(value: Record, 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; } /** @@ -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 @@ -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: @@ -113,11 +184,11 @@ 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(); @@ -127,28 +198,15 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri 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); - } - - const validation = await override.validateUuid(newUuid, build.id); - if (!validation.valid) { - return errorResponse(new Error(validation.error || 'Invalid UUID'), { status: 400 }, req); - } - 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()}`, - }); - } - - return successResponse(result.build, { status: 200 }, req); + const updatedBuild = await override.applyBuildConfigPatch({ + build, + pullRequest: build.pullRequest, + patch, + runUuid: nanoid(), + }); + + return successResponse(updatedBuild, { status: 200 }, req); } catch (error) { if (error instanceof BuildUuidValidationError) { return errorResponse(error, { status: 400 }, req); diff --git a/src/app/api/v2/builds/[uuid]/services/route.ts b/src/app/api/v2/builds/[uuid]/services/route.ts new file mode 100644 index 00000000..e281a68b --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/services/route.ts @@ -0,0 +1,164 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { nanoid } from 'nanoid'; +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import OverrideService, { + ServiceOverrideNotFoundError, + type ServiceOverridePatchInput, +} from 'server/services/override'; + +interface UpdateServiceOverridesRequest { + serviceOverrides?: unknown; +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function validateServiceOverride(value: unknown, index: number): ServiceOverridePatchInput | Error { + if (!isRecord(value)) { + return new Error(`serviceOverrides[${index}] must be an object`); + } + + const serviceName = value.serviceName; + const hasActive = hasOwn(value, 'active'); + const hasBranchOrExternalUrl = hasOwn(value, 'branchOrExternalUrl'); + + if (typeof serviceName !== 'string' || serviceName.length === 0) { + return new Error(`serviceOverrides[${index}].serviceName must be a non-empty string`); + } + + if (!hasActive && !hasBranchOrExternalUrl) { + return new Error(`serviceOverrides[${index}] requires active or branchOrExternalUrl`); + } + + if (hasActive && typeof value.active !== 'boolean') { + return new Error(`serviceOverrides[${index}].active must be a boolean`); + } + + if (hasBranchOrExternalUrl && typeof value.branchOrExternalUrl !== 'string') { + return new Error(`serviceOverrides[${index}].branchOrExternalUrl must be a string`); + } + + return { + serviceName, + ...(hasActive ? { active: value.active as boolean } : {}), + ...(hasBranchOrExternalUrl ? { branchOrExternalUrl: value.branchOrExternalUrl as string } : {}), + }; +} + +/** + * @openapi + * /api/v2/builds/{uuid}/services: + * patch: + * summary: Update service overrides in a batch + * description: Updates selected state and/or branch or external URL overrides for one or more services in a build. + * tags: + * - Builds + * operationId: updateBuildServiceOverrides + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the build. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateBuildServiceOverridesRequest' + * responses: + * '200': + * description: Service overrides updated. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildOverrideUpdateSuccessResponse' + * '400': + * description: Invalid request body. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Build or service not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const patchHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => { + const body = (await req.json().catch(() => null)) as UpdateServiceOverridesRequest | null; + const serviceOverridesBody = body?.serviceOverrides; + + if (!Array.isArray(serviceOverridesBody) || serviceOverridesBody.length === 0) { + return errorResponse(new Error('serviceOverrides must be a non-empty array'), { status: 400 }, req); + } + + const serviceOverrides: ServiceOverridePatchInput[] = []; + for (const [index, serviceOverrideBody] of serviceOverridesBody.entries()) { + const serviceOverride = validateServiceOverride(serviceOverrideBody, index); + if (serviceOverride instanceof Error) { + return errorResponse(serviceOverride, { status: 400 }, req); + } + + serviceOverrides.push(serviceOverride); + } + + const override = new OverrideService(); + const build = await override.db.models.Build.query() + .findOne({ uuid: params.uuid }) + .withGraphFetched('[pullRequest, deploys.[service, deployable]]'); + + if (!build) { + return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req); + } + + try { + const result = await override.applyServiceOverrides({ + build, + deploys: build.deploys || [], + pullRequest: build.pullRequest, + serviceOverrides, + runUuid: nanoid(), + }); + + return successResponse(result, { status: 200 }, req); + } catch (error) { + if (error instanceof ServiceOverrideNotFoundError) { + return errorResponse(error, { status: 404 }, req); + } + + throw error; + } +}; + +export const PATCH = createApiHandler(patchHandler); diff --git a/src/server/services/__tests__/activityStream.test.ts b/src/server/services/__tests__/activityStream.test.ts index 23eec66e..c350b69f 100644 --- a/src/server/services/__tests__/activityStream.test.ts +++ b/src/server/services/__tests__/activityStream.test.ts @@ -20,10 +20,7 @@ const mockLogger = { info: jest.fn(), warn: jest.fn(), }; -const mockBuildFindOne = jest.fn(); -const mockBuildQuery = jest.fn(); -const mockBuildPatch = jest.fn(); -const mockEnqueueResolveAndDeployBuild = jest.fn(); +const mockApplyBuildOverrides = jest.fn(); const mockRegisterQueue = jest.fn(); jest.mock('server/lib/dependencies', () => ({ @@ -104,6 +101,13 @@ jest.mock('server/services/buildMetadata', () => ({ default: jest.fn().mockImplementation(() => ({})), })); +jest.mock('../override', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + applyBuildOverrides: (...args: unknown[]) => mockApplyBuildOverrides(...args), + })), +})); + import ActivityStream from '../activityStream'; import { CommentParser } from 'shared/constants'; @@ -113,15 +117,7 @@ function createActivityStream() { }); const db = { - models: { - Build: { - query: mockBuildQuery, - }, - }, services: { - BuildService: { - enqueueResolveAndDeployBuild: mockEnqueueResolveAndDeployBuild, - }, Deploy: { hostForDeployableDeploy: jest.fn(), hostForServiceDeploy: jest.fn(), @@ -129,10 +125,6 @@ function createActivityStream() { }, }; - mockBuildQuery.mockReturnValue({ - findOne: mockBuildFindOne, - }); - return new ActivityStream( db as any, {} as any, @@ -146,44 +138,78 @@ function createActivityStream() { describe('ActivityStream comment overrides', () => { beforeEach(() => { jest.clearAllMocks(); - mockBuildFindOne.mockResolvedValue({ - id: 999, - uuid: 'existing-build', - }); - mockBuildPatch.mockResolvedValue(undefined); }); - it('rejects an unavailable comment UUID before applying other overrides', async () => { + it('parses comment overrides and delegates structured updates to OverrideService', async () => { const service = createActivityStream(); const build = { id: 42, uuid: 'current-build', - $query: jest.fn(() => ({ - patch: mockBuildPatch, - })), + }; + const deploys = [{ id: 1 }]; + const pullRequest = { + deployOnUpdate: true, }; const commentBody = [ CommentParser.HEADER, - 'url: existing-build', + '- [x] api: feature/api', + '- [ ] cache: main', + 'url: new-build', 'ENV:FEATURE_ENABLED:true', CommentParser.FOOTER, + '- [x] Redeploy on pushes to default branches', ].join('\n'); await (service as any).applyCommentOverrides({ build, - deploys: [], - pullRequest: { - deployOnUpdate: true, + deploys, + pullRequest, + commentBody, + runUuid: 'run-uuid', + }); + + expect(mockApplyBuildOverrides).toHaveBeenCalledWith({ + build, + deploys, + pullRequest, + runUuid: 'run-uuid', + overrides: { + serviceOverrides: [ + { + active: true, + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + { + active: false, + serviceName: 'cache', + branchOrExternalUrl: 'main', + }, + ], + vanityUrl: 'new-build', + envOverrides: { + FEATURE_ENABLED: 'true', + }, + redeployOnPush: true, }, + }); + }); + + it('does not delegate when build id is missing', async () => { + const service = createActivityStream(); + const commentBody = [CommentParser.HEADER, '- [x] api: feature/api', CommentParser.FOOTER].join('\n'); + + await (service as any).applyCommentOverrides({ + build: { + uuid: 'current-build', + }, + deploys: [], + pullRequest: {}, commentBody, runUuid: 'run-uuid', }); - expect(mockBuildFindOne).toHaveBeenCalledWith({ uuid: 'existing-build' }); - expect(mockBuildPatch).not.toHaveBeenCalled(); - expect(mockEnqueueResolveAndDeployBuild).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'UUID: comment override rejected newUuid=existing-build error=UUID is not available' - ); + expect(mockApplyBuildOverrides).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith('Build: missing for comment edit overrides'); }); }); diff --git a/src/server/services/__tests__/override.test.ts b/src/server/services/__tests__/override.test.ts new file mode 100644 index 00000000..c025c4ee --- /dev/null +++ b/src/server/services/__tests__/override.test.ts @@ -0,0 +1,869 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const mockLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +}; +const mockFallbackEnqueueResolveAndDeployBuild = jest.fn(); + +jest.mock('server/lib/dependencies', () => ({ + defaultDb: {}, + defaultRedis: {}, + defaultRedlock: {}, + defaultQueueManager: {}, +})); + +jest.mock('server/lib/logger', () => ({ + extractContextForQueue: jest.fn(() => ({ + correlationId: 'test-correlation', + })), + getLogger: jest.fn(() => mockLogger), + updateLogContext: jest.fn(), +})); + +jest.mock('server/lib/kubernetes', () => ({ + deleteNamespace: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../deploy', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + hostForDeployableDeploy: jest.fn(() => 'deployable-host'), + hostForServiceDeploy: jest.fn(() => 'service-host'), + })), +})); + +jest.mock('../build', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + enqueueResolveAndDeployBuild: mockFallbackEnqueueResolveAndDeployBuild, + })), +})); + +import OverrideService, { ApplyBuildOverridesArgs, BuildConfigPatchInput, BuildOverrideInput } from '../override'; + +const createPatchable = () => { + const patch = jest.fn().mockResolvedValue(undefined); + return { + patch, + model: { + $query: jest.fn(() => ({ + patch, + })), + }, + }; +}; + +function createService() { + const enqueueResolveAndDeployBuild = jest.fn().mockResolvedValue(undefined); + const db = { + services: { + BuildService: { + enqueueResolveAndDeployBuild, + }, + Deploy: { + hostForDeployableDeploy: jest.fn(() => 'api-public-url'), + hostForServiceDeploy: jest.fn(() => 'classic-public-url'), + }, + }, + }; + + return { + db, + enqueueResolveAndDeployBuild, + service: new OverrideService(db as any, {} as any, {} as any, {} as any), + }; +} + +function createFullYamlArgs(overrides: Partial = {}): ApplyBuildOverridesArgs { + const buildPatchable = createPatchable(); + const deployPatchable = createPatchable(); + const deployablePatchable = createPatchable(); + const dependentPatchable = createPatchable(); + + const build = { + id: 42, + uuid: 'current-build', + enableFullYaml: true, + $query: buildPatchable.model.$query, + }; + const deployable = { + name: 'api', + buildUUID: 'current-build', + buildId: 42, + $query: deployablePatchable.model.$query, + }; + const deploy = { + deployable, + service: { + id: 7, + name: 'api', + }, + $query: deployPatchable.model.$query, + }; + const dependentDeploy = { + deployable: { + name: 'api-worker', + dependsOnDeployableName: 'api', + buildUUID: 'current-build', + buildId: 42, + }, + service: { + id: 8, + name: 'api-worker', + }, + $query: dependentPatchable.model.$query, + }; + + return { + build: build as any, + deploys: [deploy, dependentDeploy] as any, + pullRequest: { + deployOnUpdate: true, + } as any, + runUuid: 'run-uuid', + overrides: { + serviceOverrides: [ + { + active: true, + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + vanityUrl: null, + envOverrides: { + FEATURE_ENABLED: 'true', + }, + redeployOnPush: true, + ...overrides, + }, + }; +} + +function createClassicArgs(overrides: Partial = {}): ApplyBuildOverridesArgs { + const buildPatchable = createPatchable(); + const deployPatchable = createPatchable(); + const deployablePatchable = createPatchable(); + const dependentPatchable = createPatchable(); + + const build = { + id: 42, + uuid: 'current-build', + enableFullYaml: false, + $query: buildPatchable.model.$query, + }; + const deployable = { + name: 'api', + buildUUID: 'current-build', + buildId: 42, + $query: deployablePatchable.model.$query, + }; + const deploy = { + deployable, + service: { + id: 7, + name: 'api', + }, + $query: deployPatchable.model.$query, + }; + const dependentDeploy = { + deployable: { + name: 'api-worker', + buildUUID: 'current-build', + buildId: 42, + }, + service: { + id: 8, + name: 'api-worker', + dependsOnServiceId: 7, + }, + $query: dependentPatchable.model.$query, + }; + + return { + build: build as any, + deploys: [deploy, dependentDeploy] as any, + pullRequest: { + deployOnUpdate: true, + } as any, + runUuid: 'run-uuid', + overrides: { + serviceOverrides: [ + { + active: true, + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + vanityUrl: null, + envOverrides: { + FEATURE_ENABLED: 'true', + }, + redeployOnPush: true, + ...overrides, + }, + }; +} + +function createBuildConfigPatchArgs(patch: BuildConfigPatchInput = {}) { + const buildPatchable = createPatchable(); + + return { + build: { + id: 42, + uuid: 'current-build', + $query: buildPatchable.model.$query, + } as any, + pullRequest: { + deployOnUpdate: true, + } as any, + patch, + runUuid: 'run-uuid', + }; +} + +describe('OverrideService.applyBuildOverrides', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('patches branch overrides on deploys and deployables', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + + await service.applyBuildOverrides(args); + + expect(args.build.$query().patch).toHaveBeenCalledWith({ + commentInitEnv: { + FEATURE_ENABLED: 'true', + }, + commentRuntimeEnv: { + FEATURE_ENABLED: 'true', + }, + trackDefaultBranches: true, + }); + expect(args.deploys[0]!.deployable!.$query().patch).toHaveBeenCalledWith({ + commentBranchName: 'feature/api', + }); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + branchName: 'feature/api', + publicUrl: 'api-public-url', + active: true, + }); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + }); + + it('patches unchecked services as inactive while preserving branch override behavior', async () => { + const { service } = createService(); + const args = createFullYamlArgs({ + serviceOverrides: [ + { + active: false, + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + }); + + await service.applyBuildOverrides(args); + + expect(args.deploys[0]!.deployable!.$query().patch).toHaveBeenCalledWith({ + commentBranchName: 'feature/api', + }); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + branchName: 'feature/api', + publicUrl: 'api-public-url', + active: false, + }); + }); + + it('patches external URL overrides without updating commentBranchName', async () => { + const { service } = createService(); + const args = createFullYamlArgs({ + serviceOverrides: [ + { + active: true, + serviceName: 'api', + branchOrExternalUrl: 'api.example.com', + }, + ], + }); + + await service.applyBuildOverrides(args); + + expect(args.deploys[0]!.deployable!.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + publicUrl: 'api.example.com', + branchName: null, + dockerImage: null, + active: true, + }); + }); + + it('cascades only active state to dependent deploys', async () => { + const { service } = createService(); + const args = createFullYamlArgs({ + serviceOverrides: [ + { + active: false, + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + }); + + await service.applyBuildOverrides(args); + + expect(args.deploys[1]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + }); + + it('rejects invalid vanity UUIDs before applying other overrides', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs({ + vanityUrl: 'existing-build', + }); + jest.spyOn(service, 'validateUuid').mockResolvedValueOnce({ + valid: false, + error: 'UUID is not available', + }); + const updateBuildUuid = jest.spyOn(service, 'updateBuildUuid'); + + await service.applyBuildOverrides(args); + + expect(args.build.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[0]!.$query().patch).not.toHaveBeenCalled(); + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + expect(updateBuildUuid).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'UUID: comment override rejected newUuid=existing-build error=UUID is not available' + ); + }); + + it('delegates valid vanity UUID updates to updateBuildUuid', async () => { + const { service } = createService(); + const args = createFullYamlArgs({ + vanityUrl: 'new-build', + }); + jest.spyOn(service, 'validateUuid').mockResolvedValueOnce({ + valid: true, + }); + const updateBuildUuid = jest.spyOn(service, 'updateBuildUuid').mockResolvedValueOnce({ + build: { + id: 42, + uuid: 'new-build', + } as any, + deploysUpdated: 2, + }); + + await service.applyBuildOverrides(args); + + expect(updateBuildUuid).toHaveBeenCalledWith(args.build, 'new-build'); + }); + + it('does not queue redeploy when deployOnUpdate is false', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + args.pullRequest.deployOnUpdate = false; + + await service.applyBuildOverrides(args); + + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('keeps comment service patch failures best-effort and still queues redeploys', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + const patchError = new Error('comment branch patch failed'); + (args.deploys[0]!.deployable!.$query().patch as jest.Mock).mockRejectedValueOnce(patchError); + + await service.applyBuildOverrides(args); + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: patchError }, + 'Deployable: patch failed service=api field=branch' + ); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + branchName: 'feature/api', + publicUrl: 'api-public-url', + active: true, + }); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + }); + + it('applies active-only service overrides for UI selection changes', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + + const result = await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.deployable!.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + expect(args.deploys[1]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + expect(result).toEqual({ + buildUuid: 'current-build', + queued: true, + status: 'success', + }); + }); + + it('applies branch-only service overrides without changing dependents', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + + await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.deployable!.$query().patch).toHaveBeenCalledWith({ + commentBranchName: 'feature/api', + }); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + branchName: 'feature/api', + publicUrl: 'api-public-url', + }); + expect(args.deploys[1]!.$query().patch).not.toHaveBeenCalled(); + }); + + it('applies external URL service overrides without updating deployable branch state', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + + await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + branchOrExternalUrl: 'api.example.com', + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.deployable!.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + publicUrl: 'api.example.com', + branchName: null, + dockerImage: null, + active: false, + }); + expect(args.deploys[1]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + }); + + it('cascades active state through service dependencies for non-full-yaml builds', async () => { + const { service } = createService(); + const args = createClassicArgs(); + + await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + expect(args.deploys[1]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + }); + + it('applies multiple service overrides and queues only once', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + const webDeployPatchable = createPatchable(); + const webDeployablePatchable = createPatchable(); + const webDeploy = { + deployable: { + name: 'web', + buildUUID: 'current-build', + buildId: 42, + $query: webDeployablePatchable.model.$query, + }, + service: { + id: 9, + name: 'web', + }, + $query: webDeployPatchable.model.$query, + }; + args.deploys.push(webDeploy as any); + + const result = await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + }, + { + serviceName: 'web', + branchOrExternalUrl: 'feature/web', + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + expect(webDeploy.deployable.$query().patch).toHaveBeenCalledWith({ + commentBranchName: 'feature/web', + }); + expect(webDeploy.$query().patch).toHaveBeenCalledWith({ + branchName: 'feature/web', + publicUrl: 'api-public-url', + }); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + buildUuid: 'current-build', + queued: true, + status: 'success', + }); + }); + + it('rejects missing services before applying batch overrides', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + + await expect( + service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + }, + { + serviceName: 'missing-service', + active: true, + }, + ], + runUuid: 'run-uuid', + }) + ).rejects.toThrow('Service missing-service not found in build'); + + expect(args.deploys[0]!.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[1]!.$query().patch).not.toHaveBeenCalled(); + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('rejects patch failures before queueing API service override redeploys', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + const patchError = new Error('deploy patch failed'); + (args.deploys[0]!.$query().patch as jest.Mock).mockRejectedValueOnce(patchError); + + await expect( + service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + }, + ], + runUuid: 'run-uuid', + }) + ).rejects.toThrow('deploy patch failed'); + + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('rejects deployable patch failures before queueing API service override redeploys', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + const patchError = new Error('deployable patch failed'); + (args.deploys[0]!.deployable!.$query().patch as jest.Mock).mockRejectedValueOnce(patchError); + + await expect( + service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + runUuid: 'run-uuid', + }) + ).rejects.toThrow('deployable patch failed'); + + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('falls back to a DeployService instance when deploy URL helpers are not wired', async () => { + const enqueueResolveAndDeployBuild = jest.fn().mockResolvedValue(undefined); + const db = { + services: { + BuildService: { + enqueueResolveAndDeployBuild, + }, + }, + }; + const service = new OverrideService(db as any, {} as any, {} as any, {} as any); + const args = createFullYamlArgs(); + + await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + serviceName: 'api', + branchOrExternalUrl: 'feature/api', + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + branchName: 'feature/api', + publicUrl: 'deployable-host', + }); + }); +}); + +describe('OverrideService.applyBuildConfigPatch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('patches only provided build config fields and queues redeploy once', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + isStatic: true, + trackDefaultBranches: false, + commentRuntimeEnv: {}, + commentInitEnv: { + BOOT: 'enabled', + }, + }); + + const result = await service.applyBuildConfigPatch(args); + + expect(args.build.$query().patch).toHaveBeenCalledWith({ + isStatic: true, + trackDefaultBranches: false, + commentRuntimeEnv: {}, + commentInitEnv: { + BOOT: 'enabled', + }, + }); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + expect(result).toMatchObject({ + uuid: 'current-build', + isStatic: true, + trackDefaultBranches: false, + commentRuntimeEnv: {}, + commentInitEnv: { + BOOT: 'enabled', + }, + }); + }); + + it('validates UUID before applying build config changes', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + uuid: 'existing-build', + isStatic: true, + commentRuntimeEnv: {}, + }); + jest.spyOn(service, 'validateUuid').mockResolvedValueOnce({ + valid: false, + error: 'UUID is not available', + }); + const updateBuildUuid = jest.spyOn(service, 'updateBuildUuid'); + + await expect(service.applyBuildConfigPatch(args)).rejects.toThrow('UUID is not available'); + + expect(args.build.$query().patch).not.toHaveBeenCalled(); + expect(updateBuildUuid).not.toHaveBeenCalled(); + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('rejects no-op UUID changes before applying build config changes', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + uuid: 'current-build', + isStatic: true, + }); + const validateUuid = jest.spyOn(service, 'validateUuid'); + + await expect(service.applyBuildConfigPatch(args)).rejects.toThrow('UUID must be different'); + + expect(validateUuid).not.toHaveBeenCalled(); + expect(args.build.$query().patch).not.toHaveBeenCalled(); + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('delegates valid UUID changes to updateBuildUuid after config patches', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + uuid: 'new-build', + isStatic: true, + }); + jest.spyOn(service, 'validateUuid').mockResolvedValueOnce({ + valid: true, + }); + const updateBuildUuid = jest.spyOn(service, 'updateBuildUuid').mockResolvedValueOnce({ + build: { + id: 42, + uuid: 'new-build', + isStatic: true, + } as any, + deploysUpdated: 2, + }); + + const result = await service.applyBuildConfigPatch(args); + + expect(args.build.$query().patch).toHaveBeenCalledWith({ + isStatic: true, + }); + expect(updateBuildUuid).toHaveBeenCalledWith(args.build, 'new-build'); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + id: 42, + uuid: 'new-build', + isStatic: true, + }); + }); + + it('supports UUID-only build config patches', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + uuid: 'new-build', + }); + jest.spyOn(service, 'validateUuid').mockResolvedValueOnce({ + valid: true, + }); + const updateBuildUuid = jest.spyOn(service, 'updateBuildUuid').mockResolvedValueOnce({ + build: { + id: 42, + uuid: 'new-build', + } as any, + deploysUpdated: 2, + }); + + const result = await service.applyBuildConfigPatch(args); + + expect(args.build.$query().patch).not.toHaveBeenCalled(); + expect(updateBuildUuid).toHaveBeenCalledWith(args.build, 'new-build'); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + expect(result).toMatchObject({ + id: 42, + uuid: 'new-build', + }); + }); + + it('does not queue redeploy when deployOnUpdate is false', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + isStatic: true, + }); + args.pullRequest.deployOnUpdate = false; + + await service.applyBuildConfigPatch(args); + + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + }); + + it('falls back to a BuildService instance when the service registry is not wired', async () => { + const db = { + services: {}, + }; + const service = new OverrideService(db as any, {} as any, {} as any, {} as any); + const args = createBuildConfigPatchArgs({ + isStatic: true, + }); + + await service.applyBuildConfigPatch(args); + + expect(mockFallbackEnqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + }); +}); diff --git a/src/server/services/activityStream.ts b/src/server/services/activityStream.ts index 42a991f5..6430ebc9 100644 --- a/src/server/services/activityStream.ts +++ b/src/server/services/activityStream.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 GoodRx, Inc. + * Copyright 2026 Lifecycle contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,8 @@ import { Build, PullRequest, Deploy, Repository } from 'server/models'; import * as github from 'server/lib/github'; import { QUEUE_NAMES, LIFECYCLE_UI_URL } from 'shared/config'; import { Metrics } from 'server/lib/metrics'; -import * as psl from 'psl'; import { CommentHelper } from 'server/lib/comment'; -import OverrideService from './override'; +import OverrideService, { type BuildOverrideInput } from './override'; import { BuildKind, BuildStatus, @@ -90,7 +89,7 @@ export default class ActivityStream extends BaseService { const { repository } = pullRequest; await this.db.services.ActivityStream.updatePullRequestActivityStream( build, - build.deploys, + build.deploys || [], pullRequest, repository, true, @@ -129,7 +128,8 @@ export default class ActivityStream extends BaseService { async updateBuildsAndDeploysFromCommentEdit(pullRequest: PullRequest, commentBody: string) { await pullRequest.$fetchGraph('[build.[deploys.[service, deployable]], repository]'); const { build, repository } = pullRequest; - const { deploys, id: buildId } = build; + const { id: buildId } = build; + const deploys = build.deploys || []; const buildUuid = build?.uuid; return withLogContext({ buildUuid }, async () => { @@ -199,112 +199,15 @@ export default class ActivityStream extends BaseService { return; } - const serviceOverrides = CommentHelper.parseServiceBranches(commentBody); - const vanityUrl = CommentHelper.parseVanityUrl(commentBody); - const envOverrides = CommentHelper.parseEnvironmentOverrides(commentBody); - const redeployOnPush = CommentHelper.parseRedeployOnPushes(commentBody); - const requestedUuid = vanityUrl && vanityUrl !== build.uuid ? vanityUrl : null; + const overrides: BuildOverrideInput = { + serviceOverrides: CommentHelper.parseServiceBranches(commentBody), + vanityUrl: CommentHelper.parseVanityUrl(commentBody), + envOverrides: CommentHelper.parseEnvironmentOverrides(commentBody), + redeployOnPush: CommentHelper.parseRedeployOnPushes(commentBody), + }; const override = new OverrideService(this.db, this.redis, this.redlock, this.queueManager); - if (requestedUuid) { - const validation = await override.validateUuid(requestedUuid, build.id); - if (!validation.valid) { - getLogger().warn(`UUID: comment override rejected newUuid=${requestedUuid} error=${validation.error}`); - return; - } - } - - getLogger().debug(`Parsed environment overrides: ${JSON.stringify(envOverrides)}`); - - await build.$query().patch({ - commentInitEnv: envOverrides, - commentRuntimeEnv: envOverrides, - trackDefaultBranches: redeployOnPush, - }); - - getLogger().debug(`Service overrides: ${JSON.stringify(serviceOverrides)}`); - - await Promise.all(serviceOverrides.map((override) => this.patchServiceOverride(build, deploys, override))); - - // handle build uuid updates here - if (requestedUuid) { - await override.updateBuildUuid(build, requestedUuid); - } - - // if pull request should be built and deployed again, add it to build queue - if (pullRequest.deployOnUpdate) { - await this.db.services.BuildService.enqueueResolveAndDeployBuild({ - buildId: build.id, - runUUID: runUuid, - ...extractContextForQueue(), - }); - } - } - - private async patchServiceOverride(build: Build, deploys: Deploy[], { active, serviceName, branchOrExternalUrl }) { - getLogger().debug(`Patching service: ${serviceName} active=${active} branch/url=${branchOrExternalUrl}`); - - const deploy: Deploy = build.enableFullYaml - ? deploys.find((d) => d.deployable.name === serviceName) - : deploys.find((d) => d.service.name === serviceName); - - if (!deploy) { - getLogger().warn(`Deploy: not found service=${serviceName}`); - return; - } - - const { service, deployable } = deploy; - - if (psl.isValid(branchOrExternalUrl)) { - // External URL override - // ??? not exactly sure where we use an external url in this context - await deploy - .$query() - .patch({ - publicUrl: branchOrExternalUrl, - branchName: null, - dockerImage: null, - active, - }) - .catch((error) => { - getLogger().error({ error }, `Deploy: patch failed service=${serviceName} field=externalUrl`); - }); - } else { - getLogger().debug(`Setting branch override: ${branchOrExternalUrl} for deployable: ${deployable?.name}`); - await deploy.deployable - .$query() - .patch({ commentBranchName: branchOrExternalUrl }) - .catch((error) => { - getLogger().error({ error }, `Deployable: patch failed service=${serviceName} field=branch`); - }); - - await deploy - .$query() - .patch({ - branchName: branchOrExternalUrl, - publicUrl: build.enableFullYaml - ? this.db.services.Deploy.hostForDeployableDeploy(deploy, deployable) - : this.db.services.Deploy.hostForServiceDeploy(deploy, service), - active, - }) - .catch((error) => { - getLogger().error({ error }, `Deploy: patch failed service=${serviceName} field=branch`); - }); - } - - // patch dependent deploys - if (build.enableFullYaml) { - const dependents = deploys.filter( - (d) => - d.deployable.dependsOnDeployableName === deploy.deployable.name && - d.deployable.buildUUID === deploy.deployable.buildUUID && - d.deployable.buildId === deploy.deployable.buildId - ); - await Promise.all(dependents.map((d) => d.$query().patch({ active }))); - } else { - const dependents = deploys.filter((d) => d.service.dependsOnServiceId === service.id); - await Promise.all(dependents.map((d) => d.$query().patch({ active }))); - } + await override.applyBuildOverrides({ build, deploys, pullRequest, overrides, runUuid }); } private async updateMissionControlComment( @@ -397,7 +300,7 @@ export default class ActivityStream extends BaseService { repository: Repository, updateMissionControl: boolean, updateStatus: boolean, - error: Error = null, + error: Error | null = null, queue: boolean = true, targetGithubRepositoryId?: number ) { diff --git a/src/server/services/override.ts b/src/server/services/override.ts index 3f7229c3..78770cd7 100644 --- a/src/server/services/override.ts +++ b/src/server/services/override.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 GoodRx, Inc. + * Copyright 2026 Lifecycle contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,11 @@ */ import BaseService from './_service'; -import { getLogger, updateLogContext } from 'server/lib/logger'; -import { Build } from 'server/models'; +import { extractContextForQueue, getLogger, updateLogContext } from 'server/lib/logger'; +import { Build, Deploy, PullRequest } from 'server/models'; import * as k8s from 'server/lib/kubernetes'; import DeployService from './deploy'; +import * as psl from 'psl'; export interface ValidationResult { valid: boolean; @@ -30,6 +31,62 @@ export interface UpdateResult { deploysUpdated: number; } +export interface ServiceOverrideInput { + active: boolean; + serviceName: string; + branchOrExternalUrl: string; +} + +export interface BuildOverrideInput { + serviceOverrides: ServiceOverrideInput[]; + vanityUrl: string | null; + envOverrides: Record; + redeployOnPush: boolean; +} + +export interface BuildConfigPatchInput { + uuid?: string; + isStatic?: boolean; + trackDefaultBranches?: boolean; + commentRuntimeEnv?: Record; + commentInitEnv?: Record; +} + +export interface ApplyBuildOverridesArgs { + build: Build; + deploys: Deploy[]; + pullRequest: PullRequest; + overrides: BuildOverrideInput; + runUuid: string; +} + +export interface ApplyBuildConfigPatchArgs { + build: Build; + pullRequest: PullRequest; + patch: BuildConfigPatchInput; + runUuid: string; +} + +export interface ServiceOverridePatchInput { + serviceName: string; + active?: boolean; + branchOrExternalUrl?: string; +} + +export interface ApplyServiceOverridesArgs { + build: Build; + deploys: Deploy[]; + pullRequest: PullRequest; + serviceOverrides: ServiceOverridePatchInput[]; + runUuid: string; +} + +export interface OverrideApplyResult { + buildUuid: string; + queued: boolean; + status: 'success'; +} + export class BuildUuidValidationError extends Error { constructor(message: string) { super(message); @@ -37,7 +94,273 @@ export class BuildUuidValidationError extends Error { } } +export class ServiceOverrideNotFoundError extends Error { + constructor(serviceName: string) { + super(`Service ${serviceName} not found in build`); + this.name = 'ServiceOverrideNotFoundError'; + } +} + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + export default class OverrideService extends BaseService { + async applyBuildOverrides({ + build, + deploys, + pullRequest, + overrides, + runUuid, + }: ApplyBuildOverridesArgs): Promise { + if (!build.id) { + getLogger().error('Build: missing for comment edit overrides'); + return; + } + + const requestedUuid = overrides.vanityUrl && overrides.vanityUrl !== build.uuid ? overrides.vanityUrl : null; + + if (requestedUuid) { + const validation = await this.validateUuid(requestedUuid, build.id); + if (!validation.valid) { + getLogger().warn(`UUID: comment override rejected newUuid=${requestedUuid} error=${validation.error}`); + return; + } + } + + getLogger().debug(`Parsed environment overrides: ${JSON.stringify(overrides.envOverrides)}`); + + await build.$query().patch({ + commentInitEnv: overrides.envOverrides, + commentRuntimeEnv: overrides.envOverrides, + trackDefaultBranches: overrides.redeployOnPush, + }); + + getLogger().debug(`Service overrides: ${JSON.stringify(overrides.serviceOverrides)}`); + + await Promise.all( + overrides.serviceOverrides.map((serviceOverride) => this.patchServiceOverride(build, deploys, serviceOverride)) + ); + + if (requestedUuid) { + await this.updateBuildUuid(build, requestedUuid); + } + + await this.enqueueRedeployIfEnabled(build, pullRequest, runUuid); + } + + async applyBuildConfigPatch({ build, pullRequest, patch, runUuid }: ApplyBuildConfigPatchArgs): Promise { + if (!build.id) { + getLogger().error('Build: missing for build config patch'); + throw new Error('Build id is required'); + } + + const requestedUuid = hasOwn(patch, 'uuid') ? patch.uuid : undefined; + if (requestedUuid != null) { + if (requestedUuid === build.uuid) { + throw new BuildUuidValidationError('UUID must be different'); + } + + const validation = await this.validateUuid(requestedUuid, build.id); + if (!validation.valid) { + throw new BuildUuidValidationError(validation.error || 'Invalid UUID'); + } + } + + const buildPatch: Record = {}; + if (hasOwn(patch, 'isStatic')) { + buildPatch.isStatic = patch.isStatic; + } + if (hasOwn(patch, 'trackDefaultBranches')) { + buildPatch.trackDefaultBranches = patch.trackDefaultBranches; + } + if (hasOwn(patch, 'commentRuntimeEnv')) { + buildPatch.commentRuntimeEnv = patch.commentRuntimeEnv; + } + if (hasOwn(patch, 'commentInitEnv')) { + buildPatch.commentInitEnv = patch.commentInitEnv; + } + + if (Object.keys(buildPatch).length > 0) { + await build.$query().patch(buildPatch); + Object.assign(build, buildPatch); + } + + let updatedBuild = build; + if (requestedUuid) { + const result = await this.updateBuildUuid(build, requestedUuid); + updatedBuild = result.build; + } + + await this.enqueueRedeployIfEnabled(updatedBuild, pullRequest, runUuid); + return updatedBuild; + } + + async applyServiceOverrides({ + build, + deploys, + pullRequest, + serviceOverrides, + runUuid, + }: ApplyServiceOverridesArgs): Promise { + if (!build.id) { + getLogger().error('Build: missing for service override'); + throw new Error('Build id is required'); + } + + if (!serviceOverrides.length) { + throw new Error('serviceOverrides is required'); + } + + for (const serviceOverride of serviceOverrides) { + if (serviceOverride.active == null && serviceOverride.branchOrExternalUrl == null) { + throw new Error('active or branchOrExternalUrl is required'); + } + + if (!this.findDeployForService(build, deploys, serviceOverride.serviceName)) { + throw new ServiceOverrideNotFoundError(serviceOverride.serviceName); + } + } + + await Promise.all( + serviceOverrides.map((serviceOverride) => this.patchServiceOverride(build, deploys, serviceOverride, true, true)) + ); + + const queued = await this.enqueueRedeployIfEnabled(build, pullRequest, runUuid); + return { + buildUuid: build.uuid, + queued, + status: 'success', + }; + } + + private async patchServiceOverride( + build: Build, + deploys: Deploy[], + { + active, + serviceName, + branchOrExternalUrl, + }: { + active?: boolean; + serviceName: string; + branchOrExternalUrl?: string; + }, + throwOnMissing = false, + throwOnPatchFailure = false + ) { + getLogger().debug(`Patching service: ${serviceName} active=${active} branch/url=${branchOrExternalUrl}`); + + const deploy = this.findDeployForService(build, deploys, serviceName); + + if (!deploy) { + getLogger().warn(`Deploy: not found service=${serviceName}`); + if (throwOnMissing) { + throw new ServiceOverrideNotFoundError(serviceName); + } + return; + } + + const { service } = deploy; + const deployable = deploy.deployable!; + + if (branchOrExternalUrl != null && psl.isValid(branchOrExternalUrl)) { + await this.patchWithFailureMode( + deploy.$query().patch({ + publicUrl: branchOrExternalUrl, + branchName: null, + dockerImage: null, + ...(active != null ? { active } : {}), + } as object), + `Deploy: patch failed service=${serviceName} field=externalUrl`, + throwOnPatchFailure + ); + } else if (branchOrExternalUrl != null) { + getLogger().debug(`Setting branch override: ${branchOrExternalUrl} for deployable: ${deployable?.name}`); + const deployService = + this.db.services?.Deploy ?? new DeployService(this.db, this.redis, this.redlock, this.queueManager); + + await this.patchWithFailureMode( + deployable.$query().patch({ commentBranchName: branchOrExternalUrl }), + `Deployable: patch failed service=${serviceName} field=branch`, + throwOnPatchFailure + ); + + await this.patchWithFailureMode( + deploy.$query().patch({ + branchName: branchOrExternalUrl, + publicUrl: build.enableFullYaml + ? deployService.hostForDeployableDeploy(deploy, deployable) + : deployService.hostForServiceDeploy(deploy, service), + ...(active != null ? { active } : {}), + }), + `Deploy: patch failed service=${serviceName} field=branch`, + throwOnPatchFailure + ); + } else if (active != null) { + await this.patchWithFailureMode( + deploy.$query().patch({ active }), + `Deploy: patch failed service=${serviceName} field=active`, + throwOnPatchFailure + ); + } + + if (active == null) { + return; + } + + if (build.enableFullYaml) { + const dependents = deploys.filter( + (d) => + d.deployable!.dependsOnDeployableName === deployable.name && + d.deployable!.buildUUID === deployable.buildUUID && + d.deployable!.buildId === deployable.buildId + ); + await Promise.all(dependents.map((d) => d.$query().patch({ active }))); + } else { + const dependents = deploys.filter((d) => d.service.dependsOnServiceId === service.id); + await Promise.all(dependents.map((d) => d.$query().patch({ active }))); + } + } + + private async patchWithFailureMode( + patchPromise: PromiseLike, + failureMessage: string, + throwOnPatchFailure: boolean + ): Promise { + try { + await patchPromise; + } catch (error) { + getLogger().error({ error }, failureMessage); + if (throwOnPatchFailure) { + throw error; + } + } + } + + private findDeployForService(build: Build, deploys: Deploy[], serviceName: string): Deploy | undefined { + return build.enableFullYaml + ? deploys.find((deploy) => deploy.deployable!.name === serviceName) + : deploys.find((deploy) => deploy.service.name === serviceName); + } + + private async enqueueRedeployIfEnabled(build: Build, pullRequest: PullRequest, runUuid: string): Promise { + if (!pullRequest.deployOnUpdate) { + return false; + } + + const buildService = + this.db.services?.BuildService ?? + new (await import('./build')).default(this.db, this.redis, this.redlock, this.queueManager); + + await buildService.enqueueResolveAndDeployBuild({ + buildId: build.id, + runUUID: runUuid, + ...extractContextForQueue(), + }); + return true; + } + /** * Validate UUID format and uniqueness * @param uuid The UUID to validate diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index 9ca6ea59..e1cf53af 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -25,6 +25,30 @@ describe('OpenAPI v2 agent session contract', () => { expect(schemas.BuildMetadataLinkPatchRequest.additionalProperties).toBe(false); }); + it('documents build config and service override routes', () => { + expect(getOperation('/api/v2/builds/{uuid}/services/{name}/override', 'patch')).toBeUndefined(); + expect(getOperation('/api/v2/builds/{uuid}/services/overrides', 'patch')).toBeUndefined(); + expect(getOperation('/api/v2/builds/{uuid}/environment-overrides', 'patch')).toBeUndefined(); + expect(getOperation('/api/v2/builds/{uuid}/options', 'patch')).toBeUndefined(); + expect(getOperation('/api/v2/builds/{uuid}', 'patch')?.tags).toEqual(['Builds']); + expect(getOperation('/api/v2/builds/{uuid}/services', 'patch')?.tags).toEqual(['Builds']); + expect(schemas.UpdateBuildServiceOverrideRequest).toBeUndefined(); + expect(schemas.UpdateBuildEnvironmentOverridesRequest).toBeUndefined(); + expect(schemas.UpdateBuildOptionsRequest).toBeUndefined(); + expect(schemas.UpdateBuildConfigPatchRequest.additionalProperties).toBe(false); + expect(schemas.UpdateBuildConfigPatchRequest.anyOf).toEqual([ + { required: ['uuid'] }, + { required: ['isStatic'] }, + { required: ['trackDefaultBranches'] }, + { required: ['commentRuntimeEnv'] }, + { required: ['commentInitEnv'] }, + ]); + expect(schemas.BuildServiceOverridePatch.required).toEqual(['serviceName']); + expect(schemas.UpdateBuildServiceOverridesRequest.required).toEqual(['serviceOverrides']); + expect(schemas.UpdateBuildServiceOverridesRequest.properties.serviceOverrides.minItems).toBe(1); + expect(schemas.BuildOverrideUpdateResult.required).toEqual(['status', 'buildUuid', 'queued']); + }); + it('documents canonical run events with public context and a version', () => { const eventSchema = schemas.AgentRunMessagePartEvent; diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 3a0e6365..6e6f0a12 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -3007,7 +3007,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, - UpdateBuildUUIDRequest: { + UpdateBuildConfigPatchRequest: { type: 'object', properties: { uuid: { @@ -3015,11 +3015,92 @@ export const openApiSpecificationForV2Api: OAS3Options = { example: 'curly-meadow-171613', description: 'The new UUID to assign to the build.', }, + isStatic: { + type: 'boolean', + example: true, + description: 'Whether this build should be treated as a static environment.', + }, + trackDefaultBranches: { + type: 'boolean', + example: true, + description: 'Whether pushes to selected default branches should redeploy this build.', + }, + commentRuntimeEnv: { + type: 'object', + example: { + FEATURE_ENABLED: 'true', + }, + description: 'Runtime environment override object to replace on the build when provided.', + }, + commentInitEnv: { + type: 'object', + example: { + MIGRATION_ENABLED: 'true', + }, + description: 'Init environment override object to replace on the build when provided.', + }, + }, + anyOf: [ + { required: ['uuid'] }, + { required: ['isStatic'] }, + { required: ['trackDefaultBranches'] }, + { required: ['commentRuntimeEnv'] }, + { required: ['commentInitEnv'] }, + ], + additionalProperties: false, + }, + + BuildServiceOverridePatch: { + type: 'object', + properties: { + serviceName: { + type: 'string', + example: 'backend', + description: 'The service name to update.', + }, + active: { + type: 'boolean', + example: true, + description: 'Whether the service should be selected for the build.', + }, + branchOrExternalUrl: { + type: 'string', + example: 'feature/api', + description: 'The branch name or external URL override for the service.', + }, + }, + required: ['serviceName'], + anyOf: [{ required: ['active'] }, { required: ['branchOrExternalUrl'] }], + additionalProperties: false, + }, + + UpdateBuildServiceOverridesRequest: { + type: 'object', + properties: { + serviceOverrides: { + type: 'array', + minItems: 1, + items: { $ref: '#/components/schemas/BuildServiceOverridePatch' }, + }, }, - required: ['uuid'], + required: ['serviceOverrides'], additionalProperties: false, }, + BuildOverrideUpdateResult: { + type: 'object', + properties: { + status: { type: 'string', enum: ['success'] }, + buildUuid: { type: 'string', example: 'curly-meadow-171613' }, + queued: { + type: 'boolean', + example: true, + description: 'Whether the build was queued for redeploy after the override update.', + }, + }, + required: ['status', 'buildUuid', 'queued'], + }, + BuildMetadataLink: { type: 'object', properties: { @@ -3230,7 +3311,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { /** * @description The specific success response for the PATCH /builds/{uuid} endpoint. */ - UpdateBuildUUIDSuccessResponse: { + UpdateBuildConfigSuccessResponse: { allOf: [ { $ref: '#/components/schemas/SuccessApiResponse' }, { @@ -3243,6 +3324,22 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + /** + * @description The specific success response for PATCH build override endpoints. + */ + BuildOverrideUpdateSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/BuildOverrideUpdateResult' }, + }, + required: ['data'], + }, + ], + }, + /** * @description The specific success response for the GET /schema/validate endpoint. */ From 1e1a3de630bcb11b167de72c5fc20fd0f9618a00 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Tue, 5 May 2026 18:46:30 -0700 Subject: [PATCH 2/3] fix: harden override redeploy queueing --- src/server/services/__tests__/build.test.ts | 18 ++++- .../services/__tests__/override.test.ts | 76 ++++++++++++++++++- src/server/services/build.ts | 3 +- src/server/services/override.ts | 14 ++-- 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/server/services/__tests__/build.test.ts b/src/server/services/__tests__/build.test.ts index 74abe87c..3f2e308c 100644 --- a/src/server/services/__tests__/build.test.ts +++ b/src/server/services/__tests__/build.test.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 GoodRx, Inc. + * Copyright 2026 Lifecycle contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,7 +46,7 @@ jest.mock('server/lib/logger', () => ({ warn: jest.fn(), debug: jest.fn(), })), - withLogContext: jest.fn((ctx, fn) => fn()), + withLogContext: jest.fn((_ctx, fn) => fn()), extractContextForQueue: jest.fn(() => ({})), updateLogContext: jest.fn(), LogStage: {}, @@ -349,6 +349,20 @@ describe('BuildService queue fingerprinting', () => { expect(baseFingerprint).not.toEqual(changedFingerprint); }); + test('changes fingerprint when static mode changes', async () => { + const previewBuild = createMockBuild({ + isStatic: false, + }); + const staticBuild = createMockBuild({ + isStatic: true, + }); + + const previewFingerprint = await buildService.computeBuildRequestFingerprint(previewBuild); + const staticFingerprint = await buildService.computeBuildRequestFingerprint(staticBuild); + + expect(previewFingerprint).not.toEqual(staticFingerprint); + }); + test('changes fingerprint when repository filter changes', async () => { const build = createMockBuild(); diff --git a/src/server/services/__tests__/override.test.ts b/src/server/services/__tests__/override.test.ts index c025c4ee..240104ed 100644 --- a/src/server/services/__tests__/override.test.ts +++ b/src/server/services/__tests__/override.test.ts @@ -385,7 +385,9 @@ describe('OverrideService.applyBuildOverrides', () => { it('does not queue redeploy when deployOnUpdate is false', async () => { const { service, enqueueResolveAndDeployBuild } = createService(); const args = createFullYamlArgs(); - args.pullRequest.deployOnUpdate = false; + args.pullRequest = { + deployOnUpdate: false, + } as any; await service.applyBuildOverrides(args); @@ -452,6 +454,37 @@ describe('OverrideService.applyBuildOverrides', () => { }); }); + it('applies service overrides without queueing when the build has no pull request', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createFullYamlArgs(); + + const result = await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: undefined, + serviceOverrides: [ + { + serviceName: 'api', + active: false, + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + expect(args.deploys[1]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + expect(result).toEqual({ + buildUuid: 'current-build', + queued: false, + status: 'success', + }); + }); + it('applies branch-only service overrides without changing dependents', async () => { const { service } = createService(); const args = createFullYamlArgs(); @@ -702,6 +735,28 @@ describe('OverrideService.applyBuildConfigPatch', () => { jest.clearAllMocks(); }); + it('patches static mode by itself and queues redeploy', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + isStatic: true, + }); + + const result = await service.applyBuildConfigPatch(args); + + expect(args.build.$query().patch).toHaveBeenCalledWith({ + isStatic: true, + }); + expect(enqueueResolveAndDeployBuild).toHaveBeenCalledWith({ + buildId: 42, + runUUID: 'run-uuid', + correlationId: 'test-correlation', + }); + expect(result).toMatchObject({ + uuid: 'current-build', + isStatic: true, + }); + }); + it('patches only provided build config fields and queues redeploy once', async () => { const { service, enqueueResolveAndDeployBuild } = createService(); const args = createBuildConfigPatchArgs({ @@ -849,6 +904,25 @@ describe('OverrideService.applyBuildConfigPatch', () => { expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); }); + it('patches build config without queueing when the build has no pull request', async () => { + const { service, enqueueResolveAndDeployBuild } = createService(); + const args = createBuildConfigPatchArgs({ + isStatic: true, + }); + args.pullRequest = undefined; + + const result = await service.applyBuildConfigPatch(args); + + expect(args.build.$query().patch).toHaveBeenCalledWith({ + isStatic: true, + }); + expect(enqueueResolveAndDeployBuild).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + uuid: 'current-build', + isStatic: true, + }); + }); + it('falls back to a BuildService instance when the service registry is not wired', async () => { const db = { services: {}, diff --git a/src/server/services/build.ts b/src/server/services/build.ts index 5fb3b4e2..2b127a08 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 GoodRx, Inc. + * Copyright 2026 Lifecycle contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -134,6 +134,7 @@ export default class BuildService extends BaseService { commentRuntimeEnv: build.commentRuntimeEnv || {}, commentInitEnv: build.commentInitEnv || {}, enableFullYaml: build.enableFullYaml ?? false, + isStatic: build.isStatic ?? false, deploys: _.sortBy(deploys, 'key'), }; } diff --git a/src/server/services/override.ts b/src/server/services/override.ts index 78770cd7..bf469d21 100644 --- a/src/server/services/override.ts +++ b/src/server/services/override.ts @@ -55,14 +55,14 @@ export interface BuildConfigPatchInput { export interface ApplyBuildOverridesArgs { build: Build; deploys: Deploy[]; - pullRequest: PullRequest; + pullRequest?: PullRequest | null; overrides: BuildOverrideInput; runUuid: string; } export interface ApplyBuildConfigPatchArgs { build: Build; - pullRequest: PullRequest; + pullRequest?: PullRequest | null; patch: BuildConfigPatchInput; runUuid: string; } @@ -76,7 +76,7 @@ export interface ServiceOverridePatchInput { export interface ApplyServiceOverridesArgs { build: Build; deploys: Deploy[]; - pullRequest: PullRequest; + pullRequest?: PullRequest | null; serviceOverrides: ServiceOverridePatchInput[]; runUuid: string; } @@ -344,8 +344,12 @@ export default class OverrideService extends BaseService { : deploys.find((deploy) => deploy.service.name === serviceName); } - private async enqueueRedeployIfEnabled(build: Build, pullRequest: PullRequest, runUuid: string): Promise { - if (!pullRequest.deployOnUpdate) { + private async enqueueRedeployIfEnabled( + build: Build, + pullRequest: PullRequest | null | undefined, + runUuid: string + ): Promise { + if (!pullRequest?.deployOnUpdate) { return false; } From 1d002bbf2d157090803ff78029f094156ab7c960 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Wed, 6 May 2026 10:53:03 -0700 Subject: [PATCH 3/3] fix: align build api docs and responses --- src/app/api/v2/builds/[uuid]/route.ts | 9 ++++++++- src/app/api/v2/builds/[uuid]/webhooks/route.ts | 4 ++-- src/shared/openApiSpec.test.ts | 10 ++++++++++ src/shared/openApiSpec.ts | 4 ++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/api/v2/builds/[uuid]/route.ts b/src/app/api/v2/builds/[uuid]/route.ts index dde757b5..1cd7a0cb 100644 --- a/src/app/api/v2/builds/[uuid]/route.ts +++ b/src/app/api/v2/builds/[uuid]/route.ts @@ -192,6 +192,7 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri } const override = new OverrideService(); + const buildService = new BuildService(); const build = await override.db.models.Build.query().findOne({ uuid: params.uuid }).withGraphFetched('pullRequest'); if (!build) { @@ -206,7 +207,13 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri runUuid: nanoid(), }); - return successResponse(updatedBuild, { status: 200 }, req); + const hydratedBuild = await buildService.getBuildByUUID(updatedBuild.uuid); + + if (!hydratedBuild) { + return errorResponse(new Error(`Build with UUID ${updatedBuild.uuid} not found`), { status: 404 }, req); + } + + return successResponse(hydratedBuild, { status: 200 }, req); } catch (error) { if (error instanceof BuildUuidValidationError) { return errorResponse(error, { status: 400 }, req); diff --git a/src/app/api/v2/builds/[uuid]/webhooks/route.ts b/src/app/api/v2/builds/[uuid]/webhooks/route.ts index 62461a27..706e55f0 100644 --- a/src/app/api/v2/builds/[uuid]/webhooks/route.ts +++ b/src/app/api/v2/builds/[uuid]/webhooks/route.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 GoodRx, Inc. + * Copyright 2026 Lifecycle contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,7 +56,7 @@ import BuildService from 'server/services/build'; * application/json: * schema: * $ref: '#/components/schemas/ApiErrorResponse' - * post: + * put: * summary: Invoke webhooks for a build * description: | * Triggers the execution of configured webhooks for a specific build. diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index e1cf53af..e1981279 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -32,6 +32,9 @@ describe('OpenAPI v2 agent session contract', () => { expect(getOperation('/api/v2/builds/{uuid}/options', 'patch')).toBeUndefined(); expect(getOperation('/api/v2/builds/{uuid}', 'patch')?.tags).toEqual(['Builds']); expect(getOperation('/api/v2/builds/{uuid}/services', 'patch')?.tags).toEqual(['Builds']); + expect(schemas.UpdateBuildConfigSuccessResponse.allOf[1].properties.data).toEqual({ + $ref: '#/components/schemas/Build', + }); expect(schemas.UpdateBuildServiceOverrideRequest).toBeUndefined(); expect(schemas.UpdateBuildEnvironmentOverridesRequest).toBeUndefined(); expect(schemas.UpdateBuildOptionsRequest).toBeUndefined(); @@ -44,11 +47,18 @@ describe('OpenAPI v2 agent session contract', () => { { required: ['commentInitEnv'] }, ]); expect(schemas.BuildServiceOverridePatch.required).toEqual(['serviceName']); + expect(schemas.BuildServiceOverridePatch.additionalProperties).toBe(true); expect(schemas.UpdateBuildServiceOverridesRequest.required).toEqual(['serviceOverrides']); expect(schemas.UpdateBuildServiceOverridesRequest.properties.serviceOverrides.minItems).toBe(1); + expect(schemas.UpdateBuildServiceOverridesRequest.additionalProperties).toBe(true); expect(schemas.BuildOverrideUpdateResult.required).toEqual(['status', 'buildUuid', 'queued']); }); + it('documents build webhooks with the implemented invoke method', () => { + expect(getOperation('/api/v2/builds/{uuid}/webhooks', 'put')?.tags).toEqual(['Builds']); + expect(getOperation('/api/v2/builds/{uuid}/webhooks', 'post')).toBeUndefined(); + }); + it('documents canonical run events with public context and a version', () => { const eventSchema = schemas.AgentRunMessagePartEvent; diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 6e6f0a12..753f97c2 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -3071,7 +3071,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, required: ['serviceName'], anyOf: [{ required: ['active'] }, { required: ['branchOrExternalUrl'] }], - additionalProperties: false, + additionalProperties: true, }, UpdateBuildServiceOverridesRequest: { @@ -3084,7 +3084,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, }, required: ['serviceOverrides'], - additionalProperties: false, + additionalProperties: true, }, BuildOverrideUpdateResult: {