diff --git a/src/server/services/__tests__/build.test.ts b/src/server/services/__tests__/build.test.ts index c8a9220..3b9ade3 100644 --- a/src/server/services/__tests__/build.test.ts +++ b/src/server/services/__tests__/build.test.ts @@ -169,3 +169,157 @@ describe('BuildService failure boundaries', () => { expect(recordDeployFailure).not.toHaveBeenCalled(); }); }); + +describe('BuildService queue fingerprinting', () => { + let buildService: BuildService; + let mockBuildQuery: any; + let mockBuildQueueAdd: jest.Mock; + let mockResolveQueueAdd: jest.Mock; + + const createMockBuild = (overrides: any = {}) => + ({ + id: 1, + enableFullYaml: true, + commentRuntimeEnv: { FEATURE_FLAG: 'on' }, + commentInitEnv: {}, + pullRequest: { latestCommit: 'abcdef123456' }, + deploys: [ + { + id: 11, + uuid: 'api-deploy', + githubRepositoryId: 100, + branchName: 'feature-branch', + active: true, + publicUrl: 'https://example.test/api', + env: { API_URL: 'https://api.test' }, + initEnv: { INIT_MODE: 'warm' }, + deployable: { name: 'api', commentBranchName: null }, + service: { name: 'api' }, + }, + { + id: 22, + uuid: 'worker-deploy', + githubRepositoryId: 200, + branchName: 'feature-branch', + active: true, + publicUrl: 'https://example.test/worker', + env: { QUEUE: 'jobs' }, + initEnv: {}, + deployable: { name: 'worker', commentBranchName: 'worker-override' }, + service: { name: 'worker' }, + }, + ], + $fetchGraph: jest.fn().mockResolvedValue(undefined), + ...overrides, + } as any); + + beforeEach(() => { + jest.clearAllMocks(); + + mockBuildQueueAdd = jest.fn().mockResolvedValue(undefined); + mockResolveQueueAdd = jest.fn().mockResolvedValue(undefined); + + mockBuildQuery = { + findOne: jest.fn().mockReturnThis(), + withGraphFetched: jest.fn(), + }; + + const queueManager = { + registerQueue: jest.fn(() => ({ + add: jest.fn(), + process: jest.fn(), + on: jest.fn(), + })), + }; + + buildService = new BuildService( + { + models: { + Build: { + query: jest.fn(() => mockBuildQuery), + }, + }, + services: {}, + } as any, + {} as any, + {} as any, + queueManager as any + ); + (buildService as any).buildQueue = { add: mockBuildQueueAdd }; + (buildService as any).resolveAndDeployBuildQueue = { add: mockResolveQueueAdd }; + }); + + test('changes fingerprint when comment runtime env changes', async () => { + const baseBuild = createMockBuild(); + const changedBuild = createMockBuild({ + commentRuntimeEnv: { FEATURE_FLAG: 'off' }, + }); + + const baseFingerprint = await buildService.computeBuildRequestFingerprint(baseBuild); + const changedFingerprint = await buildService.computeBuildRequestFingerprint(changedBuild); + + expect(baseFingerprint).not.toEqual(changedFingerprint); + }); + + test('changes fingerprint when repository filter changes', async () => { + const build = createMockBuild(); + + const apiFingerprint = await buildService.computeBuildRequestFingerprint(build, 100); + const workerFingerprint = await buildService.computeBuildRequestFingerprint(build, 200); + + expect(apiFingerprint).not.toEqual(workerFingerprint); + }); + + test('enqueues resolve queue with deduplication derived from the current build fingerprint', async () => { + const build = createMockBuild(); + mockBuildQuery.withGraphFetched.mockResolvedValue(build); + + const expectedFingerprint = await buildService.computeBuildRequestFingerprint(build, 100); + + await buildService.enqueueResolveAndDeployBuild({ + buildId: 1, + githubRepositoryId: 100, + correlationId: 'corr-1', + }); + + expect(mockResolveQueueAdd).toHaveBeenCalledWith( + 'resolve-deploy', + expect.objectContaining({ + buildId: 1, + githubRepositoryId: 100, + correlationId: 'corr-1', + }), + expect.objectContaining({ + deduplication: { + id: `resolve:1:${expectedFingerprint}`, + ttl: 30000, + }, + }) + ); + }); + + test('enqueues build queue with a deterministic job id derived from the current build fingerprint', async () => { + const build = createMockBuild(); + mockBuildQuery.withGraphFetched.mockResolvedValue(build); + + const expectedFingerprint = await buildService.computeBuildRequestFingerprint(build, 100); + + await buildService.enqueueBuildJob({ + buildId: 1, + githubRepositoryId: 100, + correlationId: 'corr-2', + }); + + expect(mockBuildQueueAdd).toHaveBeenCalledWith( + 'build', + expect.objectContaining({ + buildId: 1, + githubRepositoryId: 100, + correlationId: 'corr-2', + }), + expect.objectContaining({ + jobId: `build:1:${expectedFingerprint}`, + }) + ); + }); +}); diff --git a/src/server/services/__tests__/github.test.ts b/src/server/services/__tests__/github.test.ts index c0cf4bb..7c50fea 100644 --- a/src/server/services/__tests__/github.test.ts +++ b/src/server/services/__tests__/github.test.ts @@ -18,9 +18,13 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; import Github from '../github'; import { DeployStatus, PullRequestStatus } from 'shared/constants'; import { PushEvent } from '@octokit/webhooks-types'; +import * as githubLib from 'server/lib/github'; mockRedisClient(); +const TEST_OWNER_URL = 'https://example.invalid/example-owner'; +const TEST_REPOSITORY_FULL_NAME = 'example-owner/example-repo'; + const mockIsLifecycleLabel = jest.fn(); const mockHasDeployLabel = jest.fn(); const mockEnableKillSwitch = jest.fn(); @@ -47,6 +51,232 @@ jest.mock('server/lib/logger', () => ({ LogStage: {}, })); +jest.mock('server/lib/github', () => ({ + getYamlFileContent: jest.fn(), +})); + +const createDedupeAwareResolveEnqueue = (queueAdd: jest.Mock) => { + const queuedKeys = new Set(); + + return jest.fn(async (payload) => { + const queueKey = `${payload.buildId}:${payload.githubRepositoryId ?? 'all'}`; + if (queuedKeys.has(queueKey)) return undefined; + queuedKeys.add(queueKey); + return queueAdd('resolve-deploy', payload); + }); +}; + +describe('Github Service - handlePullRequestHook', () => { + let githubService: Github; + let mockDb: any; + let mockQueueManager: any; + const mockGetYamlFileContent = githubLib.getYamlFileContent as jest.Mock; + + const createMockPullRequestEvent = ({ labels = [] as { name: string }[], branchSha = 'abc123' } = {}) => + ({ + action: 'opened', + number: 42, + repository: { + id: 12345, + owner: { id: 777, html_url: TEST_OWNER_URL }, + name: 'repo', + full_name: TEST_REPOSITORY_FULL_NAME, + }, + installation: { id: 999 }, + pull_request: { + id: 1001, + head: { ref: 'feature-branch', sha: branchSha }, + title: 'Test PR', + user: { login: 'test-user' }, + state: 'open', + labels, + }, + } as any); + + const createMockPullRequest = (overrides: any = {}) => { + const patch = jest.fn().mockResolvedValue(undefined); + + return { + id: 1, + deployOnUpdate: false, + latestCommit: null, + githubLogin: 'test-user', + fullName: TEST_REPOSITORY_FULL_NAME, + branchName: 'feature-branch', + build: { id: 10, uuid: 'build-uuid' }, + repository: { id: 5 }, + $fetchGraph: jest.fn().mockResolvedValue(undefined), + $query: jest.fn().mockReturnValue({ + patch, + }), + __patch: patch, + ...overrides, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + const mockResolveQueueAdd = jest.fn().mockResolvedValue(undefined); + const mockEnqueueResolveAndDeployBuild = createDedupeAwareResolveEnqueue(mockResolveQueueAdd); + + mockDb = { + models: { + Build: { + findOne: jest.fn().mockResolvedValue({ id: 10 }), + }, + PullRequest: { + findOne: jest.fn().mockResolvedValue(null), + }, + }, + services: { + Repository: { + findRepository: jest.fn().mockResolvedValue({ + id: 5, + defaultEnvId: 15, + githubInstallationId: 999, + }), + }, + PullRequest: { + findOrCreatePullRequest: jest.fn().mockResolvedValue(createMockPullRequest()), + }, + BuildService: { + createBuildAndDeploys: jest.fn().mockResolvedValue(undefined), + enqueueResolveAndDeployBuild: mockEnqueueResolveAndDeployBuild, + resolveAndDeployBuildQueue: { + add: mockResolveQueueAdd, + }, + }, + LabelService: { + labelQueue: { + add: jest.fn().mockResolvedValue(undefined), + }, + }, + BotUser: { + isBotUser: jest.fn().mockResolvedValue(false), + }, + }, + }; + + mockQueueManager = { + registerQueue: jest.fn().mockReturnValue({ + add: jest.fn(), + process: jest.fn(), + on: jest.fn(), + }), + }; + + githubService = new Github(mockDb, {}, {}, mockQueueManager); + }); + + test('queues initial build when a non-autoDeploy PR is opened with the deploy label', async () => { + mockGetYamlFileContent.mockResolvedValue({ environment: { autoDeploy: false } }); + mockHasDeployLabel.mockResolvedValue(true); + mockEnableKillSwitch.mockResolvedValue(false); + + const mockPullRequest = createMockPullRequest(); + mockDb.services.PullRequest.findOrCreatePullRequest.mockResolvedValue(mockPullRequest); + + await githubService.handlePullRequestHook( + createMockPullRequestEvent({ + labels: [{ name: 'lifecycle-deploy!' }], + }) + ); + + expect(mockDb.services.BuildService.createBuildAndDeploys).toHaveBeenCalled(); + expect(mockDb.models.Build.findOne).toHaveBeenCalledWith({ pullRequestId: 1 }); + expect(mockDb.services.BuildService.resolveAndDeployBuildQueue.add).toHaveBeenCalledWith('resolve-deploy', { + buildId: 10, + }); + expect(mockDb.services.LabelService.labelQueue.add).not.toHaveBeenCalled(); + expect(mockPullRequest.__patch).toHaveBeenCalledWith( + expect.objectContaining({ + deployOnUpdate: true, + labels: JSON.stringify(['lifecycle-deploy!']), + }) + ); + }); + + test('queues initial build and skips label sync when an autoDeploy PR is opened with the deploy label', async () => { + mockGetYamlFileContent.mockResolvedValue({ environment: { autoDeploy: true } }); + mockHasDeployLabel.mockResolvedValue(true); + mockEnableKillSwitch.mockResolvedValue(false); + + const mockPullRequest = createMockPullRequest({ deployOnUpdate: true }); + mockDb.services.PullRequest.findOrCreatePullRequest.mockResolvedValue(mockPullRequest); + + await githubService.handlePullRequestHook( + createMockPullRequestEvent({ + labels: [{ name: 'lifecycle-deploy!' }], + }) + ); + + expect(mockDb.services.BuildService.resolveAndDeployBuildQueue.add).toHaveBeenCalledWith('resolve-deploy', { + buildId: 10, + }); + expect(mockDb.services.LabelService.labelQueue.add).not.toHaveBeenCalled(); + }); + + test('keeps the existing label sync flow for unlabeled autoDeploy PRs', async () => { + mockGetYamlFileContent.mockResolvedValue({ environment: { autoDeploy: true } }); + mockHasDeployLabel.mockResolvedValue(false); + mockEnableKillSwitch.mockResolvedValue(false); + + const mockPullRequest = createMockPullRequest({ deployOnUpdate: true }); + mockDb.services.PullRequest.findOrCreatePullRequest.mockResolvedValue(mockPullRequest); + + await githubService.handlePullRequestHook( + createMockPullRequestEvent({ + labels: [], + }) + ); + + expect(mockDb.services.BuildService.resolveAndDeployBuildQueue.add).not.toHaveBeenCalled(); + expect(mockDb.services.LabelService.labelQueue.add).toHaveBeenCalledWith( + 'label', + expect.objectContaining({ + pullRequestId: 1, + action: 'enable', + waitForComment: true, + labels: [], + }) + ); + }); + + test('queues one effective build across labeled -> opened -> labeled for a pre-labeled autoDeploy PR', async () => { + mockGetYamlFileContent.mockResolvedValue({ environment: { autoDeploy: true } }); + mockHasDeployLabel.mockResolvedValue(true); + mockEnableKillSwitch.mockResolvedValue(false); + mockIsLifecycleLabel.mockImplementation(async (label: string) => label.startsWith('lifecycle-')); + + const mockPullRequest = createMockPullRequest({ deployOnUpdate: true }); + mockDb.services.PullRequest.findOrCreatePullRequest.mockResolvedValue(mockPullRequest); + mockDb.models.PullRequest.findOne.mockResolvedValue(mockPullRequest); + + const openEvent = createMockPullRequestEvent({ + labels: [{ name: 'question' }, { name: 'lifecycle-deploy!' }], + }); + const createLabelEvent = (changedLabel: string) => ({ + action: 'labeled', + label: { name: changedLabel }, + pull_request: { + id: 1001, + labels: [{ name: 'question' }, { name: 'lifecycle-deploy!' }], + state: 'open', + }, + }); + + await githubService.handleLabelWebhook(createLabelEvent('question')); + await githubService.handlePullRequestHook(openEvent); + await githubService.handleLabelWebhook(createLabelEvent('lifecycle-deploy!')); + + expect(mockDb.services.BuildService.resolveAndDeployBuildQueue.add).toHaveBeenCalledTimes(1); + expect(mockDb.services.BuildService.resolveAndDeployBuildQueue.add).toHaveBeenCalledWith('resolve-deploy', { + buildId: 10, + }); + expect(mockDb.services.LabelService.labelQueue.add).not.toHaveBeenCalled(); + }); +}); + describe('Github Service - handlePushWebhook', () => { let githubService: Github; let mockDb: any; @@ -98,6 +328,8 @@ describe('Github Service - handlePushWebhook', () => { beforeEach(() => { jest.clearAllMocks(); + const mockResolveQueueAdd = jest.fn().mockResolvedValue(undefined); + const mockEnqueueResolveAndDeployBuild = createDedupeAwareResolveEnqueue(mockResolveQueueAdd); mockDb = { models: { @@ -110,8 +342,9 @@ describe('Github Service - handlePushWebhook', () => { }, services: { BuildService: { + enqueueResolveAndDeployBuild: mockEnqueueResolveAndDeployBuild, resolveAndDeployBuildQueue: { - add: jest.fn().mockResolvedValue(undefined), + add: mockResolveQueueAdd, }, }, }, @@ -352,7 +585,7 @@ describe('Github Service - handleLabelWebhook', () => { id: 1, deployOnUpdate: false, githubLogin: 'test-user', - fullName: 'org/repo', + fullName: TEST_REPOSITORY_FULL_NAME, branchName: 'feature-branch', build: { id: 10, uuid: 'build-uuid' }, repository: { id: 5 }, @@ -366,6 +599,8 @@ describe('Github Service - handleLabelWebhook', () => { beforeEach(() => { jest.clearAllMocks(); + const mockResolveQueueAdd = jest.fn().mockResolvedValue(undefined); + const mockEnqueueResolveAndDeployBuild = jest.fn((payload) => mockResolveQueueAdd('resolve-deploy', payload)); mockDb = { models: { @@ -376,8 +611,9 @@ describe('Github Service - handleLabelWebhook', () => { services: { BuildService: { deleteBuild: jest.fn().mockResolvedValue(undefined), + enqueueResolveAndDeployBuild: mockEnqueueResolveAndDeployBuild, resolveAndDeployBuildQueue: { - add: jest.fn().mockResolvedValue(undefined), + add: mockResolveQueueAdd, }, }, BotUser: { diff --git a/src/server/services/activityStream.ts b/src/server/services/activityStream.ts index b92cc20..bdeaf00 100644 --- a/src/server/services/activityStream.ts +++ b/src/server/services/activityStream.ts @@ -145,7 +145,7 @@ export default class ActivityStream extends BaseService { try { if (isRedeployRequested) { getLogger().info('Deploy: redeploy reason=commentEdit'); - await this.db.services.BuildService.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.db.services.BuildService.enqueueResolveAndDeployBuild({ buildId, runUUID: runUuid, ...extractContextForQueue(), @@ -223,7 +223,7 @@ export default class ActivityStream extends BaseService { // if pull request should be built and deployed again, add it to build queue if (pullRequest.deployOnUpdate) { - await this.db.services.BuildService.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.db.services.BuildService.enqueueResolveAndDeployBuild({ buildId: build.id, runUUID: runUuid, ...extractContextForQueue(), diff --git a/src/server/services/build.ts b/src/server/services/build.ts index e056227..c31c180 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -28,6 +28,7 @@ import { BuildKind, BuildStatus, CLIDeployTypes, DeployStatus, DeployTypes } fro import { type DeployOptions } from './deploy'; import DeployService from './deploy'; import BaseService from './_service'; +import hash from 'object-hash'; import _ from 'lodash'; import { QUEUE_NAMES } from 'shared/config'; import { LifecycleError } from 'server/lib/errors'; @@ -52,6 +53,8 @@ import { compactStatusMessage, statusMessageFromError } from 'server/lib/termina const tracer = Tracer.getInstance(); tracer.initialize('build-service'); +const RESOLVE_QUEUE_DEDUP_TTL_MS = 30000; + export interface IngressConfiguration { host: string; altHosts?: string[]; @@ -99,6 +102,117 @@ export default class BuildService extends BaseService { } } + private async getBuildForQueueFingerprint(buildId: number): Promise { + return this.db.models.Build.query() + .findOne({ id: buildId }) + .withGraphFetched('[pullRequest, deploys.[service, deployable]]'); + } + + private getBuildFingerprintDeployKey(build: Build, deploy: Deploy): string { + if (build.enableFullYaml) { + return deploy.deployable?.name || deploy.uuid || String(deploy.id || ''); + } + + return deploy.service?.name || deploy.deployable?.name || deploy.uuid || String(deploy.id || ''); + } + + private buildFingerprintPayload(build: Build, githubRepositoryId?: number) { + const deploys = (build.deploys || []) + .filter((deploy) => !githubRepositoryId || deploy.githubRepositoryId === githubRepositoryId) + .map((deploy) => ({ + key: this.getBuildFingerprintDeployKey(build, deploy), + githubRepositoryId: deploy.githubRepositoryId ?? null, + branchName: deploy.branchName ?? null, + active: deploy.active ?? true, + publicUrl: deploy.publicUrl ?? null, + env: deploy.env || {}, + initEnv: deploy.initEnv || {}, + commentBranchName: deploy.deployable?.commentBranchName ?? null, + })); + + return { + buildId: build.id, + githubRepositoryId: githubRepositoryId ?? null, + latestCommit: build.pullRequest?.latestCommit ?? null, + commentRuntimeEnv: build.commentRuntimeEnv || {}, + commentInitEnv: build.commentInitEnv || {}, + enableFullYaml: build.enableFullYaml ?? false, + deploys: _.sortBy(deploys, 'key'), + }; + } + + async computeBuildRequestFingerprint(buildOrId: Build | number, githubRepositoryId?: number): Promise { + const build = + typeof buildOrId === 'number' ? await this.getBuildForQueueFingerprint(buildOrId) : (buildOrId as Build); + + if (!build) { + throw new Error(`Build not found for fingerprint`); + } + + if (!build.pullRequest || !build.deploys) { + await build.$fetchGraph('[pullRequest, deploys.[service, deployable]]'); + } + + return hash(this.buildFingerprintPayload(build, githubRepositoryId)); + } + + async enqueueResolveAndDeployBuild({ + buildId, + githubRepositoryId, + ...jobData + }: { + buildId: number; + githubRepositoryId?: number | null; + [key: string]: any; + }) { + const fingerprint = await this.computeBuildRequestFingerprint(buildId, githubRepositoryId ?? undefined); + const dedupeId = `resolve:${buildId}:${fingerprint}`; + getLogger({ stage: LogStage.BUILD_QUEUED }).info( + `Build queue: name=resolve-deploy buildId=${buildId} scope=${githubRepositoryId || 'all'} dedupeKey=${dedupeId}` + ); + return this.resolveAndDeployBuildQueue.add( + 'resolve-deploy', + { + buildId, + ...(githubRepositoryId ? { githubRepositoryId } : {}), + ...jobData, + }, + { + deduplication: { + id: dedupeId, + ttl: RESOLVE_QUEUE_DEDUP_TTL_MS, + }, + } + ); + } + + async enqueueBuildJob({ + buildId, + githubRepositoryId, + ...jobData + }: { + buildId: number; + githubRepositoryId?: number | null; + [key: string]: any; + }) { + const fingerprint = await this.computeBuildRequestFingerprint(buildId, githubRepositoryId ?? undefined); + const jobId = `build:${buildId}:${fingerprint}`; + getLogger({ stage: LogStage.BUILD_QUEUED }).info( + `Build queue: name=build buildId=${buildId} scope=${githubRepositoryId || 'all'} jobId=${jobId}` + ); + return this.buildQueue.add( + 'build', + { + buildId, + ...(githubRepositoryId ? { githubRepositoryId } : {}), + ...jobData, + }, + { + jobId, + } + ); + } + /** * Returns a list of all of the active builds */ @@ -270,7 +384,7 @@ export default class BuildService extends BaseService { const runUUID = nanoid(); - await this.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.enqueueResolveAndDeployBuild({ buildId, githubRepositoryId, runUUID, @@ -317,7 +431,7 @@ export default class BuildService extends BaseService { const buildId = build.id; - await this.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.enqueueResolveAndDeployBuild({ buildId, runUUID: nanoid(), correlationId, @@ -1676,7 +1790,7 @@ export default class BuildService extends BaseService { return; } // Enqueue a standard resolve build - await this.db.services.BuildService.buildQueue.add('build', { + await this.enqueueBuildJob({ buildId, githubRepositoryId, ...extractContextForQueue(), diff --git a/src/server/services/github.ts b/src/server/services/github.ts index a6f082f..5e8cd28 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -35,6 +35,11 @@ import { createOrUpdateGithubDeployment, deleteGithubDeploymentAndEnvironment } import { enableKillSwitch, isStaging, hasDeployLabel, isLifecycleLabel } from 'server/lib/utils'; import { redisClient } from 'server/lib/dependencies'; +interface PullRequestPatchState { + deployLabelPresent: boolean; + deployOnUpdate: boolean; +} + export default class GithubService extends Service { // Handle the pull request webhook mapping the entrance with webhook body async handlePullRequestHook({ @@ -56,7 +61,6 @@ export default class GithubService extends Service { labels, }, }: PullRequestEvent) { - getLogger({}).info(`PR: ${action} repo=${fullName} branch=${branch}`); const isOpened = [GithubPullRequestActions.OPENED, GithubPullRequestActions.REOPENED].includes( action as GithubPullRequestActions ); @@ -108,13 +112,20 @@ export default class GithubService extends Service { branch, }); - await this.patchPullRequest({ + const pullRequestState = await this.patchPullRequest({ pullRequest, labels, action, status, autoDeploy, }); + getLogger({}).info( + `PR state: action=${action} repo=${fullName} branch=${branch} labels=[${labels + .map((label) => label.name) + .join(',')}] deployLabelPresent=${pullRequestState?.deployLabelPresent} deployOnUpdate=${ + pullRequestState?.deployOnUpdate + }` + ); const pullRequestId = pullRequest?.id; const latestCommit = pullRequest?.latestCommit; @@ -122,8 +133,6 @@ export default class GithubService extends Service { if (isOpened) { if (!latestCommit) await pullRequest.$query().patch({ latestCommit: branchSha }); const environmentId = repository?.defaultEnvId; - await pullRequest.$query().first(); - const isDeploy = pullRequest?.deployOnUpdate; // only create build and deploys. do not build or deploy here await this.db.services.BuildService.createBuildAndDeploys({ repositoryId, @@ -134,8 +143,32 @@ export default class GithubService extends Service { lifecycleConfig, }); - // if auto deploy, add deploy label via queue - if (isDeploy) { + const shouldQueueBuildFromOpen = + pullRequestState?.deployOnUpdate === true && pullRequestState?.deployLabelPresent === true; + const deployDecision = shouldQueueBuildFromOpen + ? 'queue-build' + : pullRequestState?.deployOnUpdate + ? 'sync-deploy-label' + : 'no-deploy'; + getLogger({}).info( + `PR open decision: repo=${fullName} branch=${branch} pullRequestId=${pullRequestId} decision=${deployDecision}` + ); + + if (shouldQueueBuildFromOpen) { + build = await this.db.models.Build.findOne({ + pullRequestId, + }); + if (!build) { + getLogger({}).warn(`Build: not found for opened PR repo=${fullName}/${branch}`); + } else { + await this.db.services.BuildService.enqueueResolveAndDeployBuild({ + buildId: build.id, + ...extractContextForQueue(), + }); + } + } else if (pullRequestState?.deployOnUpdate) { + // If autoDeploy is enabled but the label was not on the opened PR payload, + // add it asynchronously and let the follow-up label webhook advance the build. await this.db.services.LabelService.labelQueue.add('label', { pullRequestId: pullRequest.id, action: 'enable', @@ -204,7 +237,7 @@ export default class GithubService extends Service { const changedLabelName = changedLabel?.name?.toLowerCase(); const isLifecycle = await isLifecycleLabel(changedLabelName); if (!isLifecycle) { - getLogger().info(`Label: skipping non-lifecycle label=${changedLabelName} action=${action}`); + getLogger().debug(`PR label: skipping label=${changedLabelName} action=${action}`); return; } @@ -231,11 +264,18 @@ export default class GithubService extends Service { status, autoDeploy: false, }); - getLogger().info(`Label: ${action} labels=[${labels.map(({ name }) => name).join(',')}]`); + getLogger().info( + `PR label state: action=${action} changedLabel=${changedLabelName} pullRequestId=${ + pullRequest.id + } labels=[${labels.map(({ name }) => name).join(',')}] deployOnUpdate=${pullRequest.deployOnUpdate}` + ); if (pullRequest.deployOnUpdate === false) { // when pullRequest.deployOnUpdate is false, it means that there is no `lifecycle-deploy!` label // or there is `lifecycle-disabled!` label in the PR + getLogger().info( + `PR label decision: action=${action} changedLabel=${changedLabelName} pullRequestId=${pullRequest.id} decision=delete-build` + ); return this.db.services.BuildService.deleteBuild(build); } @@ -243,7 +283,7 @@ export default class GithubService extends Service { if (!buildId) { getLogger().error(`Build: id not found for=handleLabelWebhook`); } - await this.db.services.BuildService.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.db.services.BuildService.enqueueResolveAndDeployBuild({ buildId, ...extractContextForQueue(), }); @@ -331,7 +371,7 @@ export default class GithubService extends Service { getLogger().info(`Push: deploying repo=${repoName} branch=${branchName}`); } - await this.db.services.BuildService.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.db.services.BuildService.enqueueResolveAndDeployBuild({ buildId, ...(hasFailedDeploys ? {} : { githubRepositoryId }), ...extractContextForQueue(), @@ -376,7 +416,7 @@ export default class GithubService extends Service { if (!build) return; getLogger().info(`Push: redeploying reason=staticEnv`); - await this.db.services.BuildService.resolveAndDeployBuildQueue.add('resolve-deploy', { + await this.db.services.BuildService.enqueueResolveAndDeployBuild({ buildId: build?.id, ...extractContextForQueue(), }); @@ -503,7 +543,13 @@ export default class GithubService extends Service { }); }; - private patchPullRequest = async ({ pullRequest, labels, action, status, autoDeploy = false }) => { + private patchPullRequest = async ({ + pullRequest, + labels, + action, + status, + autoDeploy = false, + }): Promise => { const labelNames = labels.map(({ name }) => name.toLowerCase()) || []; const user = pullRequest?.githubLogin; const fullName = pullRequest?.fullName; @@ -525,6 +571,10 @@ export default class GithubService extends Service { deployOnUpdate: isDeployOnUpdate, labels: JSON.stringify(labelNames), }); + return { + deployLabelPresent, + deployOnUpdate: isDeployOnUpdate, + }; } catch (error) { getLogger().error({ error }, `PR: patch failed repo=${pullRequest?.fullName}/${branch}`); }