From bc9648483b0a630dde61dfa5867bc3ae3408b6d0 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Fri, 8 May 2026 11:31:00 -0700 Subject: [PATCH] feat: add build service override state endpoint --- .../api/v2/builds/[uuid]/services/route.ts | 76 ++++++- .../services/__tests__/override.test.ts | 194 ++++++++++++++++- src/server/services/override.ts | 205 +++++++++++++++++- src/shared/openApiSpec.test.ts | 30 ++- src/shared/openApiSpec.ts | 118 +++++++++- 5 files changed, 597 insertions(+), 26 deletions(-) diff --git a/src/app/api/v2/builds/[uuid]/services/route.ts b/src/app/api/v2/builds/[uuid]/services/route.ts index e281a68..ba2d07d 100644 --- a/src/app/api/v2/builds/[uuid]/services/route.ts +++ b/src/app/api/v2/builds/[uuid]/services/route.ts @@ -19,6 +19,7 @@ import { NextRequest } from 'next/server'; import { createApiHandler } from 'server/lib/createApiHandler'; import { errorResponse, successResponse } from 'server/lib/response'; import OverrideService, { + ServiceOverrideNotEditableError, ServiceOverrideNotFoundError, type ServiceOverridePatchInput, } from 'server/services/override'; @@ -40,12 +41,12 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride return new Error(`serviceOverrides[${index}] must be an object`); } - const serviceName = value.serviceName; + const name = value.name; 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 (typeof name !== 'string' || name.length === 0) { + return new Error(`serviceOverrides[${index}].name must be a non-empty string`); } if (!hasActive && !hasBranchOrExternalUrl) { @@ -61,7 +62,7 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride } return { - serviceName, + name, ...(hasActive ? { active: value.active as boolean } : {}), ...(hasBranchOrExternalUrl ? { branchOrExternalUrl: value.branchOrExternalUrl as string } : {}), }; @@ -70,6 +71,38 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride /** * @openapi * /api/v2/builds/{uuid}/services: + * get: + * summary: Get service override edit state + * description: Returns the current PR-comment-aligned service state for editing service selection and branch or external URL overrides. + * tags: + * - Builds + * operationId: getBuildServiceOverrides + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the build. + * responses: + * '200': + * description: Service override edit state. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/GetBuildServiceOverridesSuccessResponse' + * '404': + * description: Build not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' * 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. @@ -95,7 +128,7 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride * content: * application/json: * schema: - * $ref: '#/components/schemas/BuildOverrideUpdateSuccessResponse' + * $ref: '#/components/schemas/UpdateBuildServiceOverridesSuccessResponse' * '400': * description: Invalid request body. * content: @@ -115,6 +148,21 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ +const getHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => { + const override = new OverrideService(); + const build = await override.db.models.Build.query() + .findOne({ uuid: params.uuid }) + .withGraphFetched('[environment.[defaultServices, optionalServices], deploys.[service, deployable]]'); + + if (!build) { + return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req); + } + + const serviceOverrides = await override.getServiceOverrideStates(build, build.deploys || []); + + return successResponse({ serviceOverrides }, { status: 200 }, req); +}; + const patchHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => { const body = (await req.json().catch(() => null)) as UpdateServiceOverridesRequest | null; const serviceOverridesBody = body?.serviceOverrides; @@ -136,7 +184,7 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri const override = new OverrideService(); const build = await override.db.models.Build.query() .findOne({ uuid: params.uuid }) - .withGraphFetched('[pullRequest, deploys.[service, deployable]]'); + .withGraphFetched('[pullRequest, environment.[defaultServices, optionalServices], deploys.[service, deployable]]'); if (!build) { return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req); @@ -150,15 +198,29 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri serviceOverrides, runUuid: nanoid(), }); + const updatedBuild = await override.db.models.Build.query() + .findOne({ uuid: params.uuid }) + .withGraphFetched('[environment.[defaultServices, optionalServices], deploys.[service, deployable]]'); - return successResponse(result, { status: 200 }, req); + if (!updatedBuild) { + return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req); + } + + const updatedServiceOverrides = await override.getServiceOverrideStates(updatedBuild, updatedBuild.deploys || []); + + return successResponse({ serviceOverrides: updatedServiceOverrides, queued: result.queued }, { status: 200 }, req); } catch (error) { if (error instanceof ServiceOverrideNotFoundError) { return errorResponse(error, { status: 404 }, req); } + if (error instanceof ServiceOverrideNotEditableError) { + return errorResponse(error, { status: 400 }, req); + } + throw error; } }; +export const GET = createApiHandler(getHandler); export const PATCH = createApiHandler(patchHandler); diff --git a/src/server/services/__tests__/override.test.ts b/src/server/services/__tests__/override.test.ts index 0949de7..c4da190 100644 --- a/src/server/services/__tests__/override.test.ts +++ b/src/server/services/__tests__/override.test.ts @@ -57,6 +57,7 @@ jest.mock('../build', () => ({ })); import OverrideService, { ApplyBuildOverridesArgs, BuildConfigPatchInput, BuildOverrideInput } from '../override'; +import { DeployTypes } from 'shared/constants'; const createPatchable = () => { const patch = jest.fn().mockResolvedValue(undefined); @@ -107,26 +108,37 @@ function createFullYamlArgs(overrides: Partial = {}): ApplyB name: 'api', buildUUID: 'current-build', buildId: 42, + active: true, + type: DeployTypes.GITHUB, $query: deployablePatchable.model.$query, }; const deploy = { + active: true, + branchName: 'main', + publicUrl: 'api-public-url', deployable, service: { id: 7, name: 'api', + type: DeployTypes.GITHUB, }, $query: deployPatchable.model.$query, }; const dependentDeploy = { + active: true, deployable: { name: 'api-worker', dependsOnDeployableName: 'api', + dependsOnServiceId: 7, buildUUID: 'current-build', buildId: 42, + active: true, + type: DeployTypes.GITHUB, }, service: { id: 8, name: 'api-worker', + type: DeployTypes.GITHUB, }, $query: dependentPatchable.model.$query, }; @@ -166,32 +178,50 @@ function createClassicArgs(overrides: Partial = {}): ApplyBu id: 42, uuid: 'current-build', enableFullYaml: false, + environment: { + defaultServices: [ + { + id: 7, + }, + ], + optionalServices: [], + }, $query: buildPatchable.model.$query, }; const deployable = { name: 'api', buildUUID: 'current-build', buildId: 42, + type: DeployTypes.GITHUB, $query: deployablePatchable.model.$query, }; const deploy = { + serviceId: 7, + active: true, + branchName: 'main', + publicUrl: 'classic-public-url', deployable, service: { id: 7, name: 'api', + type: DeployTypes.GITHUB, }, $query: deployPatchable.model.$query, }; const dependentDeploy = { + serviceId: 8, + active: true, deployable: { name: 'api-worker', buildUUID: 'current-build', buildId: 42, + type: DeployTypes.GITHUB, }, service: { id: 8, name: 'api-worker', dependsOnServiceId: 7, + type: DeployTypes.GITHUB, }, $query: dependentPatchable.model.$query, }; @@ -428,7 +458,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -464,7 +494,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: undefined, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -495,7 +525,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', branchOrExternalUrl: 'feature/api', }, ], @@ -522,7 +552,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, branchOrExternalUrl: 'api.example.com', }, @@ -552,7 +582,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -577,12 +607,18 @@ describe('OverrideService.applyBuildOverrides', () => { name: 'web', buildUUID: 'current-build', buildId: 42, + active: true, + type: DeployTypes.GITHUB, $query: webDeployablePatchable.model.$query, }, service: { id: 9, name: 'web', + type: DeployTypes.GITHUB, }, + active: true, + branchName: 'main', + publicUrl: 'web-public-url', $query: webDeployPatchable.model.$query, }; args.deploys.push(webDeploy as any); @@ -593,11 +629,11 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, { - serviceName: 'web', + name: 'web', branchOrExternalUrl: 'feature/web', }, ], @@ -633,11 +669,11 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, { - serviceName: 'missing-service', + name: 'missing-service', active: true, }, ], @@ -663,7 +699,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -687,7 +723,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', branchOrExternalUrl: 'feature/api', }, ], @@ -716,7 +752,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', branchOrExternalUrl: 'feature/api', }, ], @@ -728,6 +764,140 @@ describe('OverrideService.applyBuildOverrides', () => { publicUrl: 'deployable-host', }); }); + + it('returns full-yaml service override edit state and excludes internal dependencies', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + const dockerDeploy = { + active: false, + status: 'built', + statusMessage: 'Ready', + updatedAt: '2026-05-08T12:00:00.000Z', + deployable: { + name: 'worker', + active: false, + type: DeployTypes.DOCKER, + dockerImage: 'repo/worker', + defaultTag: 'latest', + }, + service: { + id: 9, + name: 'worker', + type: DeployTypes.DOCKER, + }, + }; + args.deploys.push(dockerDeploy as any); + + await expect(service.getServiceOverrideStates(args.build, args.deploys)).resolves.toEqual([ + expect.objectContaining({ + name: 'api', + active: true, + branchOrExternalUrl: 'main', + group: 'default', + editable: true, + }), + expect.objectContaining({ + name: 'worker', + active: false, + branchOrExternalUrl: 'repo/worker@latest', + status: 'built', + statusMessage: 'Ready', + updatedAt: '2026-05-08T12:00:00.000Z', + group: 'optional', + editable: false, + }), + ]); + }); + + it('returns classic service override edit state grouped by environment membership', async () => { + const { service } = createService(); + const args = createClassicArgs(); + const optionalDeploy = { + serviceId: 9, + active: false, + branchName: 'feature/worker', + publicUrl: 'worker-public-url', + deployable: { + name: 'worker', + type: DeployTypes.HELM, + }, + service: { + id: 9, + name: 'worker', + type: DeployTypes.HELM, + }, + }; + (args.build.environment!.optionalServices as any[]).push({ id: 9 }); + args.deploys.push(optionalDeploy as any); + + await expect(service.getServiceOverrideStates(args.build, args.deploys)).resolves.toEqual([ + expect.objectContaining({ + name: 'api', + branchOrExternalUrl: 'main', + group: 'default', + editable: true, + }), + expect.objectContaining({ + name: 'worker', + branchOrExternalUrl: 'feature/worker', + group: 'optional', + editable: true, + }), + ]); + }); + + it('ignores unchanged display-only branch values while applying active changes', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + args.deploys[0]!.deployable!.type = DeployTypes.DOCKER; + args.deploys[0]!.deployable!.dockerImage = 'repo/api'; + args.deploys[0]!.deployable!.defaultTag = 'latest'; + + await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + name: 'api', + active: false, + branchOrExternalUrl: 'repo/api@latest', + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.deployable!.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + }); + + it('rejects changed display-only branch values before patching', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + args.deploys[0]!.deployable!.type = DeployTypes.DOCKER; + args.deploys[0]!.deployable!.dockerImage = 'repo/api'; + args.deploys[0]!.deployable!.defaultTag = 'latest'; + + await expect( + service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + name: 'api', + active: false, + branchOrExternalUrl: 'repo/api@changed', + }, + ], + runUuid: 'run-uuid', + }) + ).rejects.toThrow('Service api branchOrExternalUrl is not editable'); + + expect(args.deploys[0]!.$query().patch).not.toHaveBeenCalled(); + }); }); describe('OverrideService.validateUuid', () => { diff --git a/src/server/services/override.ts b/src/server/services/override.ts index 7de6d84..9c03b17 100644 --- a/src/server/services/override.ts +++ b/src/server/services/override.ts @@ -21,6 +21,7 @@ import { Build, Deploy, PullRequest } from 'server/models'; import * as k8s from 'server/lib/kubernetes'; import DeployService from './deploy'; import * as psl from 'psl'; +import { DeployTypes } from 'shared/constants'; export interface ValidationResult { valid: boolean; @@ -69,7 +70,7 @@ export interface ApplyBuildConfigPatchArgs { } export interface ServiceOverridePatchInput { - serviceName: string; + name: string; active?: boolean; branchOrExternalUrl?: string; } @@ -88,6 +89,17 @@ export interface OverrideApplyResult { status: 'success'; } +export interface BuildServiceOverrideState { + name: string; + active: boolean; + branchOrExternalUrl: string | null; + status: string | null; + statusMessage: string | null; + updatedAt: string | Date | null; + group: 'default' | 'optional'; + editable: boolean; +} + export class BuildUuidValidationError extends Error { constructor(message: string) { super(message); @@ -102,10 +114,58 @@ export class ServiceOverrideNotFoundError extends Error { } } +export class ServiceOverrideNotEditableError extends Error { + constructor(serviceName: string) { + super(`Service ${serviceName} branchOrExternalUrl is not editable`); + this.name = 'ServiceOverrideNotEditableError'; + } +} + function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } +function isBranchOrExternalUrlEditable(deployType?: DeployTypes): boolean { + if (!deployType) { + return false; + } + + return [DeployTypes.GITHUB, DeployTypes.HELM, DeployTypes.EXTERNAL_HTTP].includes(deployType); +} + +function getDockerDisplayValue(dockerImage?: string | null, defaultTag?: string | null): string | null { + if (dockerImage && defaultTag) { + return `${dockerImage}@${defaultTag}`; + } + + return dockerImage || defaultTag || null; +} + +function getBranchOrExternalUrl(deploy: Deploy, deployConfig?: any): string | null | undefined { + switch (deployConfig?.type) { + case DeployTypes.GITHUB: + return deploy.branchName ?? deploy.publicUrl ?? null; + case DeployTypes.HELM: + case DeployTypes.CODEFRESH: + case DeployTypes.CONFIGURATION: + return deploy.branchName ?? null; + case DeployTypes.EXTERNAL_HTTP: + return deploy.publicUrl ?? deployConfig.defaultPublicUrl ?? null; + case DeployTypes.DOCKER: + return getDockerDisplayValue(deployConfig.dockerImage, deployConfig.defaultTag); + default: + return undefined; + } +} + +function sortServiceOverrideStates(left: BuildServiceOverrideState, right: BuildServiceOverrideState): number { + if (left.group !== right.group) { + return left.group === 'default' ? -1 : 1; + } + + return left.name.localeCompare(right.name); +} + export default class OverrideService extends BaseService { async applyBuildOverrides({ build, @@ -213,18 +273,59 @@ export default class OverrideService extends BaseService { throw new Error('serviceOverrides is required'); } + const overrideStates = await this.getServiceOverrideStates(build, deploys); + const overrideStateByName = new Map(overrideStates.map((state) => [state.name, state])); + const sanitizedServiceOverrides: ServiceOverridePatchInput[] = []; + 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); + if (!this.findDeployForService(build, deploys, serviceOverride.name)) { + throw new ServiceOverrideNotFoundError(serviceOverride.name); + } + + const overrideState = overrideStateByName.get(serviceOverride.name); + if (!overrideState) { + throw new ServiceOverrideNotFoundError(serviceOverride.name); } + + const sanitizedServiceOverride = { ...serviceOverride }; + if (serviceOverride.branchOrExternalUrl != null && !overrideState.editable) { + if (serviceOverride.branchOrExternalUrl !== overrideState.branchOrExternalUrl) { + throw new ServiceOverrideNotEditableError(serviceOverride.name); + } + + delete sanitizedServiceOverride.branchOrExternalUrl; + } + + if (sanitizedServiceOverride.active != null || sanitizedServiceOverride.branchOrExternalUrl != null) { + sanitizedServiceOverrides.push(sanitizedServiceOverride); + } + } + + if (sanitizedServiceOverrides.length === 0) { + return { + buildUuid: build.uuid, + queued: false, + status: 'success', + }; } await Promise.all( - serviceOverrides.map((serviceOverride) => this.patchServiceOverride(build, deploys, serviceOverride, true, true)) + sanitizedServiceOverrides.map((serviceOverride) => + this.patchServiceOverride( + build, + deploys, + { + ...serviceOverride, + serviceName: serviceOverride.name, + }, + true, + true + ) + ) ); const queued = await this.enqueueRedeployIfEnabled(build, pullRequest, runUuid); @@ -235,6 +336,22 @@ export default class OverrideService extends BaseService { }; } + async getServiceOverrideStates(build: Build, deploys: Deploy[]): Promise { + if (build.enableFullYaml) { + return deploys + .map((deploy) => this.getFullYamlServiceOverrideState(deploy)) + .filter((state): state is BuildServiceOverrideState => state != null) + .sort(sortServiceOverrideStates); + } + + const groups = await this.getClassicServiceGroups(build); + + return deploys + .map((deploy) => this.getClassicServiceOverrideState(deploy, groups)) + .filter((state): state is BuildServiceOverrideState => state != null) + .sort(sortServiceOverrideStates); + } + private async patchServiceOverride( build: Build, deploys: Deploy[], @@ -345,6 +462,86 @@ export default class OverrideService extends BaseService { : deploys.find((deploy) => deploy.service.name === serviceName); } + private getFullYamlServiceOverrideState(deploy: Deploy): BuildServiceOverrideState | null { + const { deployable } = deploy; + + if (!deployable || deployable.dependsOnServiceId != null) { + return null; + } + + return this.getServiceOverrideStateForDeploy(deploy, deployable.name, deployable.active ? 'default' : 'optional'); + } + + private async getClassicServiceGroups(build: Build): Promise<{ + defaultServiceIds: Set; + optionalServiceIds: Set; + }> { + if (!build.environment && typeof build.$fetchGraph === 'function') { + await build.$fetchGraph('environment'); + } + + if ( + build.environment && + (!Array.isArray(build.environment.defaultServices) || !Array.isArray(build.environment.optionalServices)) && + typeof build.environment.$fetchGraph === 'function' + ) { + await build.environment.$fetchGraph('[defaultServices, optionalServices]'); + } + + return { + defaultServiceIds: new Set((build.environment?.defaultServices || []).map((service) => service.id)), + optionalServiceIds: new Set((build.environment?.optionalServices || []).map((service) => service.id)), + }; + } + + private getClassicServiceOverrideState( + deploy: Deploy, + { + defaultServiceIds, + optionalServiceIds, + }: { + defaultServiceIds: Set; + optionalServiceIds: Set; + } + ): BuildServiceOverrideState | null { + if (!deploy.service) { + return null; + } + + const serviceId = deploy.serviceId ?? deploy.service.id; + const group = defaultServiceIds.has(serviceId) ? 'default' : optionalServiceIds.has(serviceId) ? 'optional' : null; + if (!group) { + return null; + } + + return this.getServiceOverrideStateForDeploy(deploy, deploy.service.name, group); + } + + private getServiceOverrideStateForDeploy( + deploy: Deploy, + name: string, + group: 'default' | 'optional' + ): BuildServiceOverrideState | null { + const deployConfig = deploy.deployable || deploy.service; + const deployType = deployConfig?.type; + const branchOrExternalUrl = getBranchOrExternalUrl(deploy, deployConfig); + + if (branchOrExternalUrl === undefined) { + return null; + } + + return { + name, + active: deploy.active, + branchOrExternalUrl, + status: deploy.status ?? null, + statusMessage: deploy.statusMessage ?? null, + updatedAt: deploy.updatedAt ?? null, + group, + editable: isBranchOrExternalUrlEditable(deployType), + }; + } + private async enqueueRedeployIfEnabled( build: Build, pullRequest: PullRequest | null | undefined, diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index e528337..d7ba32b 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -31,6 +31,7 @@ describe('OpenAPI v2 agent session contract', () => { 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', 'get')?.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', @@ -65,11 +66,38 @@ describe('OpenAPI v2 agent session contract', () => { { required: ['commentRuntimeEnv'] }, { required: ['commentInitEnv'] }, ]); - expect(schemas.BuildServiceOverridePatch.required).toEqual(['serviceName']); + expect(schemas.BuildServiceOverridePatch.required).toEqual(['name']); expect(schemas.BuildServiceOverridePatch.additionalProperties).toBe(true); + expect(schemas.BuildServiceOverrideState.required).toEqual([ + 'name', + 'active', + 'branchOrExternalUrl', + 'status', + 'statusMessage', + 'updatedAt', + 'group', + 'editable', + ]); + expect(schemas.BuildServiceOverrideState.properties.group.enum).toEqual(['default', 'optional']); expect(schemas.UpdateBuildServiceOverridesRequest.required).toEqual(['serviceOverrides']); expect(schemas.UpdateBuildServiceOverridesRequest.properties.serviceOverrides.minItems).toBe(1); expect(schemas.UpdateBuildServiceOverridesRequest.additionalProperties).toBe(true); + expect( + schemas.GetBuildServiceOverridesSuccessResponse.allOf[1].properties.data.properties.serviceOverrides + ).toEqual({ + type: 'array', + items: { $ref: '#/components/schemas/BuildServiceOverrideState' }, + }); + expect( + schemas.UpdateBuildServiceOverridesSuccessResponse.allOf[1].properties.data.properties.serviceOverrides + ).toEqual({ + type: 'array', + items: { $ref: '#/components/schemas/BuildServiceOverrideState' }, + }); + expect(schemas.UpdateBuildServiceOverridesSuccessResponse.allOf[1].properties.data.required).toEqual([ + 'serviceOverrides', + 'queued', + ]); expect(schemas.BuildOverrideUpdateResult.required).toEqual(['status', 'buildUuid', 'queued']); }); diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 837cfd4..3a79954 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -3073,7 +3073,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { BuildServiceOverridePatch: { type: 'object', properties: { - serviceName: { + name: { type: 'string', example: 'backend', description: 'The service name to update.', @@ -3089,11 +3089,70 @@ export const openApiSpecificationForV2Api: OAS3Options = { description: 'The branch name or external URL override for the service.', }, }, - required: ['serviceName'], + required: ['name'], anyOf: [{ required: ['active'] }, { required: ['branchOrExternalUrl'] }], additionalProperties: true, }, + BuildServiceOverrideState: { + type: 'object', + properties: { + name: { + type: 'string', + example: 'backend', + description: 'The service name.', + }, + active: { + type: 'boolean', + example: true, + description: 'Whether the service is selected for the build.', + }, + branchOrExternalUrl: { + type: 'string', + nullable: true, + example: 'feature/api', + description: 'The current branch, external URL, or display-only value shown for the service.', + }, + status: { + type: 'string', + nullable: true, + example: 'deployed', + description: 'Current deploy status for the service.', + }, + statusMessage: { + type: 'string', + nullable: true, + example: 'Successfully deployed', + description: 'Current deploy status message for the service.', + }, + updatedAt: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the deploy was last updated.', + }, + group: { + type: 'string', + enum: ['default', 'optional'], + description: 'Whether the service belongs to the default or optional service group.', + }, + editable: { + type: 'boolean', + description: 'Whether branchOrExternalUrl can be edited for this service.', + }, + }, + required: [ + 'name', + 'active', + 'branchOrExternalUrl', + 'status', + 'statusMessage', + 'updatedAt', + 'group', + 'editable', + ], + }, + UpdateBuildServiceOverridesRequest: { type: 'object', properties: { @@ -3344,6 +3403,61 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + /** + * @description The specific success response for the GET /builds/{uuid}/services endpoint. + */ + GetBuildServiceOverridesSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + serviceOverrides: { + type: 'array', + items: { $ref: '#/components/schemas/BuildServiceOverrideState' }, + }, + }, + required: ['serviceOverrides'], + }, + }, + required: ['data'], + }, + ], + }, + + /** + * @description The specific success response for the PATCH /builds/{uuid}/services endpoint. + */ + UpdateBuildServiceOverridesSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + serviceOverrides: { + type: 'array', + items: { $ref: '#/components/schemas/BuildServiceOverrideState' }, + }, + queued: { + type: 'boolean', + example: true, + description: 'Whether the build was queued for redeploy after the override update.', + }, + }, + required: ['serviceOverrides', 'queued'], + }, + }, + required: ['data'], + }, + ], + }, + /** * @description The specific success response for PATCH build override endpoints. */