diff --git a/src/job/jobHandler.ts b/src/job/jobHandler.ts index 5b854fefa..dbd9b6718 100644 --- a/src/job/jobHandler.ts +++ b/src/job/jobHandler.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { Payload, Job, JobStatus } from '../entities/job'; import { JobRepository } from '../repositories/jobRepository'; import { RepoBranchesRepository } from '../repositories/repoBranchesRepository'; @@ -366,6 +366,7 @@ export abstract class JobHandler { process.env.IS_FEATURE_BRANCH !== 'true' ) { await this.callGatsbyCloudWebhook(); + await this.callNetlifyWebhook(); } } await this._logger.save(this.currJob._id, `${'(BUILD)'.padEnd(15)}Finished Build`); @@ -591,6 +592,7 @@ export abstract class JobHandler { if (this.name === 'Staging' && isFeaturePreviewWebhookEnabled) { await this.callGatsbyCloudWebhook(); await this.logger.save(job._id, 'Gatsby Webhook Called'); + await this.callNetlifyWebhook(); } const oasPageBuilderFunc = async () => oasPageBuild({ job, baseUrl }); @@ -793,16 +795,11 @@ export abstract class JobHandler { // Invokes Gatsby Cloud Preview Webhook protected async callGatsbyCloudWebhook(): Promise { - const featurePreviewWebhookEnabled = process.env.GATSBY_CLOUD_PREVIEW_WEBHOOK_ENABLED; - // Logging for Debugging purposes only will remove once we see the build working in Gatsby. - await this.logger.save( - this.currJob._id, - `${'(GATSBY_CLOUD_PREVIEW_WEBHOOK_ENABLED)'.padEnd(15)}${featurePreviewWebhookEnabled}` - ); + const previewWebhookURL = 'https://webhook.gatsbyjs.com/hooks/data_source'; + const githubUsername = this.currJob.user; + const logPrefix = '(POST Gatsby Cloud Webhook Status)'; try { - const previewWebhookURL = 'https://webhook.gatsbyjs.com/hooks/data_source'; - const githubUsername = this.currJob.user; const gatsbySiteId = await this._repoEntitlementsRepo.getGatsbySiteIdByGithubUsername(githubUsername); if (!gatsbySiteId) { const message = `User ${githubUsername} does not have a Gatsby Cloud Site ID.`; @@ -811,25 +808,52 @@ export abstract class JobHandler { } const url = `${previewWebhookURL}/${gatsbySiteId}`; - const response = await axios.post( - url, - { - jobId: this.currJob._id, - }, - { - headers: { 'x-gatsby-cloud-data-source': 'gatsby-source-snooty-preview' }, - } - ); - await this._jobRepository.updateExecutionTime(this.currJob._id, { gatsbyCloudStartTime: new Date() }); - await this.logger.save(this.currJob._id, `${'(POST Webhook Status)'.padEnd(15)}${response.status}`); + const response = await this.callExternalBuildHook(url, 'gatsbyCloudStartTime'); + await this.logger.save(this.currJob._id, `${logPrefix.padEnd(15)} ${response.status}`); } catch (err) { await this.logger.save( this.currJob._id, - `${'(POST Webhook)'.padEnd(15)}Failed to POST to Gatsby Cloud webhook: ${err}` + `${logPrefix.padEnd(15)} Failed to POST to Gatsby Cloud webhook: ${err}` ); throw err; } } + + // Invokes Netlify Build Hook + protected async callNetlifyWebhook(): Promise { + const githubUsername = this.currJob.user; + const logPrefix = '(EXPERIMENTAL - POST Netlify Webhook Status)'; + + try { + const url = await this._repoEntitlementsRepo.getNetlifyBuildHookByGithubUsername(githubUsername); + if (!url) { + const message = `User ${githubUsername} does not have a Netlify build hook.`; + this._logger.warn('Netlify Build Hook', message); + return; + } + + const res = await this.callExternalBuildHook(url, 'netlifyStartTime'); + await this.logger.save(this.currJob._id, `${logPrefix.padEnd(15)} ${res.status}`); + } catch (err) { + // Intentionally log and don't throw error since this is currently experimental and shouldn't block build progress + const errorMsg = `${logPrefix.padEnd( + 15 + )} Failed to POST to Netlify webhook. This should not affect completion of the build: ${err}`; + await this.logger.save(this.currJob._id, errorMsg); + } + } + + protected async callExternalBuildHook(url: string, startTimeKey: string): Promise { + const jobId = this.currJob._id; + const payload = { jobId }; + const config = { + headers: { 'x-gatsby-cloud-data-source': 'gatsby-source-snooty-preview' }, + }; + + const res = await axios.post(url, payload, config); + await this._jobRepository.updateExecutionTime(jobId, { [startTimeKey]: new Date() }); + return res; + } } // Good to have this as a friend function diff --git a/src/repositories/repoEntitlementsRepository.ts b/src/repositories/repoEntitlementsRepository.ts index 02d1a772e..f9cdcde26 100644 --- a/src/repositories/repoEntitlementsRepository.ts +++ b/src/repositories/repoEntitlementsRepository.ts @@ -48,18 +48,22 @@ export class RepoEntitlementsRepository extends BaseRepository { } async getGatsbySiteIdByGithubUsername(githubUsername: string): Promise { + return this.getBuildHookByGithubUsername(githubUsername, 'gatsby_site_id'); + } + + async getNetlifyBuildHookByGithubUsername(githubUsername: string): Promise { + return this.getBuildHookByGithubUsername(githubUsername, 'netlify_build_hook'); + } + + async getBuildHookByGithubUsername(githubUsername: string, fieldName: string): Promise { const query = { github_username: githubUsername }; - const projection = { _id: 0, gatsby_site_id: 1 }; + const projection = { _id: 0, [fieldName]: 1 }; const res = await this.findOne( query, `Mongo Timeout Error: Timedout while retrieving entitlements for ${githubUsername}`, { projection } ); - if (!res) { - this._logger.error('Fetching Gatsby Cloud Site ID', `Could not find user: ${githubUsername}`); - return undefined; - } - return res.gatsby_site_id; + return res ? res[fieldName] : undefined; } async getRepoEntitlementsBySlackUserId(slackUserId: string): Promise { diff --git a/tests/unit/job/stagingJobHandler.test.ts b/tests/unit/job/stagingJobHandler.test.ts index f0955bae1..0b576d497 100644 --- a/tests/unit/job/stagingJobHandler.test.ts +++ b/tests/unit/job/stagingJobHandler.test.ts @@ -1,13 +1,22 @@ +import axios from 'axios'; import { TestDataProvider } from '../../data/data'; import { JobHandlerTestHelper } from '../../utils/jobHandlerTestHelper'; import { getStagingJobDef } from '../../data/jobDef'; +import { JobStatus } from '../../../src/entities/job'; describe('StagingJobHandler Tests', () => { let jobHandlerTestHelper: JobHandlerTestHelper; + let spyPost; beforeEach(() => { jobHandlerTestHelper = new JobHandlerTestHelper(); jobHandlerTestHelper.init('staging'); + spyPost = jest.spyOn(axios, 'post'); + }); + + afterEach(() => { + process.env.GATSBY_CLOUD_PREVIEW_WEBHOOK_ENABLED = 'false'; + spyPost.mockClear(); }); test('Construct Production Handler', () => { @@ -82,9 +91,50 @@ describe('StagingJobHandler Tests', () => { expect(jobHandlerTestHelper.jobRepo.insertJob).toBeCalledTimes(0); }); - test('Staging deploy with Gatsby Cloud site does not result in job completion', async () => { - jobHandlerTestHelper.setStageForDeploySuccess(false, undefined, { hasGatsbySiteId: true }); - await jobHandlerTestHelper.jobHandler.execute(); - expect(jobHandlerTestHelper.jobRepo.updateWithStatus).toBeCalledTimes(0); + describe('Gatsby Cloud build hooks', () => { + beforeEach(() => { + process.env.GATSBY_CLOUD_PREVIEW_WEBHOOK_ENABLED = 'true'; + }); + + test('Staging with Gatsby Cloud site does not result in immediate job completion', async () => { + jobHandlerTestHelper.setStageForDeploySuccess(false, undefined, { hasGatsbySiteId: true }); + await jobHandlerTestHelper.jobHandler.execute(); + // Post-build webhook is expected to update the status + expect(jobHandlerTestHelper.jobRepo.updateWithStatus).toBeCalledTimes(0); + }); + + test('Gatsby Cloud build hook fail results in job failure', async () => { + jobHandlerTestHelper.setStageForDeploySuccess(false, undefined, { hasGatsbySiteId: true }); + spyPost.mockImplementationOnce(() => Promise.reject({})); + await jobHandlerTestHelper.jobHandler.execute(); + expect(jobHandlerTestHelper.jobRepo.updateWithErrorStatus).toBeCalledTimes(1); + }); + }); + + describe('Netlify build hooks', () => { + beforeEach(() => { + process.env.GATSBY_CLOUD_PREVIEW_WEBHOOK_ENABLED = 'true'; + }); + + test('Staging with Netlify does not result in immediate job completion with Gatsby Cloud', async () => { + jobHandlerTestHelper.setStageForDeploySuccess(false, undefined, { + hasGatsbySiteId: true, + hasNetlifyBuildHook: true, + }); + await jobHandlerTestHelper.jobHandler.execute(); + // Post-build webhook is expected to update the status + expect(jobHandlerTestHelper.jobRepo.updateWithStatus).toBeCalledTimes(0); + }); + + test('Netlify build hook error does not interfere with job execution', async () => { + jobHandlerTestHelper.setStageForDeploySuccess(false, undefined, { hasNetlifyBuildHook: true }); + spyPost.mockImplementationOnce(() => Promise.reject({})); + await jobHandlerTestHelper.jobHandler.execute(); + expect(jobHandlerTestHelper.jobRepo.updateWithStatus).toBeCalledWith( + expect.anything(), + undefined, + JobStatus.completed + ); + }); }); }); diff --git a/tests/utils/jobHandlerTestHelper.ts b/tests/utils/jobHandlerTestHelper.ts index ffcf6af5d..a6968ee4d 100644 --- a/tests/utils/jobHandlerTestHelper.ts +++ b/tests/utils/jobHandlerTestHelper.ts @@ -20,6 +20,7 @@ import { getBuildJobDef, getManifestJobDef, getStagingJobDef } from '../data/job type MockReturnValueOnce = { status: string; output: string; error: string | null }; type SetupOptions = { hasGatsbySiteId?: boolean; + hasNetlifyBuildHook?: boolean; }; export class JobHandlerTestHelper { @@ -88,10 +89,13 @@ export class JobHandlerTestHelper { this.setupForSuccess(); const publishOutput = TestDataProvider.getPublishOutputWithPurgedUrls(prodDeploy); - const { hasGatsbySiteId } = setupOptions; + const { hasGatsbySiteId, hasNetlifyBuildHook } = setupOptions; if (hasGatsbySiteId) { this.repoEntitlementsRepo.getGatsbySiteIdByGithubUsername.mockResolvedValue('gatsby_site_id'); } + if (hasNetlifyBuildHook) { + this.repoEntitlementsRepo.getNetlifyBuildHookByGithubUsername.mockResolvedValue('netlify_build_hook'); + } if (returnValue) { this.jobCommandExecutor.execute.mockResolvedValue(returnValue);