From e19d9cdbf6c5dc15c101bc66cc46f90dff3e639d Mon Sep 17 00:00:00 2001 From: Patrick Hulce Date: Thu, 3 Oct 2019 18:08:37 -0500 Subject: [PATCH] feat(server): add route for computing build ancestor --- packages/server/src/api/routes/projects.js | 14 +++ packages/server/src/api/storage/sql/sql.js | 46 ++++++++++ .../server/src/api/storage/storage-method.js | 10 +++ packages/server/test/server-test-suite.js | 90 +++++++++++++++++-- packages/utils/src/api-client.js | 11 +++ 5 files changed, 163 insertions(+), 8 deletions(-) diff --git a/packages/server/src/api/routes/projects.js b/packages/server/src/api/routes/projects.js index 26f0ad215..5f368405a 100644 --- a/packages/server/src/api/routes/projects.js +++ b/packages/server/src/api/routes/projects.js @@ -117,6 +117,20 @@ function createRouter(context) { }) ); + // GET /projects/:id/builds/:id/ancestor + router.get( + '/:projectId/builds/:buildId/ancestor', + handleAsyncError(async (req, res) => { + const build = await context.storageMethod.findAncestorBuildById( + req.params.projectId, + req.params.buildId + ); + + if (!build) return res.sendStatus(404); + res.json(build); + }) + ); + // GET /projects//builds//runs router.get( '/:projectId/builds/:buildId/runs', diff --git a/packages/server/src/api/storage/sql/sql.js b/packages/server/src/api/storage/sql/sql.js index 186ad078e..0f159324b 100644 --- a/packages/server/src/api/storage/sql/sql.js +++ b/packages/server/src/api/storage/sql/sql.js @@ -303,6 +303,52 @@ class SqlStorageMethod { return clone(build || undefined); } + /** + * @param {string} projectId + * @param {string} buildId + * @return {Promise} + */ + async findAncestorBuildById(projectId, buildId) { + const {buildModel} = this._sql(); + const build = await this._findByPk(buildModel, buildId); + if (!build || (build && build.projectId !== projectId)) return undefined; + + if (build.ancestorHash) { + const ancestorsByHash = await this._findAll(buildModel, { + where: {projectId: build.projectId, hash: build.ancestorHash}, + limit: 1, + }); + + if (ancestorsByHash.length) return clone(ancestorsByHash[0]); + } + + const where = { + projectId: build.projectId, + branch: 'master', + id: {[Sequelize.Op.ne]: build.id}, + }; + + const nearestBuildAfter = await buildModel.findAll({ + where: {...where, runAt: {[Sequelize.Op.gte]: build.runAt}}, + order: [['runAt', 'ASC']], + limit: 1, + }); + + const nearestBuildBefore = await buildModel.findAll({ + where: {...where, runAt: {[Sequelize.Op.lte]: build.runAt}}, + order: [['runAt', 'DESC']], + limit: 1, + }); + + /** @param {string} date */ + const getDateDistance = date => + Math.abs(new Date(date).getTime() - new Date(build.runAt).getTime()); + const candidates = nearestBuildBefore + .concat(nearestBuildAfter) + .sort((a, b) => getDateDistance(a.runAt) - getDateDistance(b.runAt)); + return clone(candidates[0]); + } + /** * @param {string} projectId * @param {string} buildId diff --git a/packages/server/src/api/storage/storage-method.js b/packages/server/src/api/storage/storage-method.js index fdf9f829d..308301c88 100644 --- a/packages/server/src/api/storage/storage-method.js +++ b/packages/server/src/api/storage/storage-method.js @@ -96,6 +96,16 @@ class StorageMethod { throw new Error('Unimplemented'); } + /** + * @param {string} projectId + * @param {string} buildId + * @return {Promise} + */ + // eslint-disable-next-line no-unused-vars + async findAncestorBuildById(projectId, buildId) { + throw new Error('Unimplemented'); + } + /** * @param {StrictOmit} unsavedBuild * @return {Promise} diff --git a/packages/server/test/server-test-suite.js b/packages/server/test/server-test-suite.js index e30bbbc6c..e7385e758 100644 --- a/packages/server/test/server-test-suite.js +++ b/packages/server/test/server-test-suite.js @@ -18,6 +18,7 @@ function runTests(state) { let buildA; let buildB; let buildC; + let buildD; let runA; let runB; let runC; @@ -103,7 +104,7 @@ function runTests(state) { author: 'Paul Irish ', avatarUrl: 'https://avatars1.githubusercontent.com/u/39191?s=460&v=4', commitMessage: 'feat: add some more awesome features', - ancestorHash: 'e0acdd50ed0fdcfdceb2508498be50cc55c696ef', + ancestorHash: buildA.hash, runAt: new Date().toISOString(), }; @@ -113,6 +114,26 @@ function runTests(state) { expect(buildB).toMatchObject(payload); }); + it('should create a 3rd build (w/o an ancestor)', async () => { + const payload = { + projectId: projectA.id, + lifecycle: 'unsealed', + hash: '50cc55c696eb25084ebe0acdd50ed0fdcfdce98a', + branch: 'other_branch', + externalBuildUrl: 'http://travis-ci.org/org/repo/2', + author: 'Paul Irish ', + avatarUrl: 'https://avatars1.githubusercontent.com/u/39191?s=460&v=4', + commitMessage: 'feat: a branch without an ancestor', + ancestorHash: '', + runAt: new Date().toISOString(), + }; + + buildC = await client.createBuild(payload); + expect(buildC).toHaveProperty('id'); + expect(buildC.projectId).toEqual(projectA.id); + expect(buildC).toMatchObject(payload); + }); + it('should create a build in different project', async () => { const payload = { projectId: projectB.id, @@ -127,15 +148,15 @@ function runTests(state) { runAt: new Date().toISOString(), }; - buildC = await client.createBuild(payload); - expect(buildC).toHaveProperty('id'); - expect(buildC.projectId).toEqual(projectB.id); - expect(buildC).toMatchObject(payload); + buildD = await client.createBuild(payload); + expect(buildD).toHaveProperty('id'); + expect(buildD.projectId).toEqual(projectB.id); + expect(buildD).toMatchObject(payload); }); it('should list builds', async () => { const builds = await client.getBuilds(projectA.id); - expect(builds).toEqual([buildB, buildA]); + expect(builds).toEqual([buildC, buildB, buildA]); }); it('should list builds filtered by branch', async () => { @@ -150,7 +171,7 @@ function runTests(state) { it('should list builds for another project', async () => { const builds = await client.getBuilds(projectB.id); - expect(builds).toEqual([buildC]); + expect(builds).toEqual([buildD]); }); it('should list builds for missing project', async () => { @@ -172,7 +193,11 @@ function runTests(state) { describe('/:projectId/branches', () => { it('should list branches', async () => { const branches = await client.getBranches(projectA.id); - expect(branches).toEqual([{branch: 'test_branch'}, {branch: 'master'}]); + expect(branches).toEqual([ + {branch: 'test_branch'}, + {branch: 'other_branch'}, + {branch: 'master'}, + ]); }); it('should handle missing ids', async () => { @@ -181,6 +206,55 @@ function runTests(state) { }); }); + describe('/:projectId/builds/:buildId/ancestor', () => { + it('should not find a build with no ancestor', async () => { + const build = await client.findAncestorBuildById(buildA.projectId, buildA.id); + expect(build).toEqual(undefined); + }); + + it('should find a build with an explicit ancestor', async () => { + const build = await client.findAncestorBuildById(buildB.projectId, buildB.id); + expect(build).toEqual(buildA); + }); + + it('should find a build with an implicit prior ancestor', async () => { + const build = await client.findAncestorBuildById(buildC.projectId, buildC.id); + expect(build).toEqual(buildA); + }); + + it('should find a build with complicated ancestor', async () => { + const project = await client.createProject(projectA); + const buildWithoutAncestor = await client.createBuild({ + ...buildC, + projectId: project.id, + branch: 'feature_branch', + runAt: new Date('2019-09-01').toISOString(), + }); + + let ancestor = await client.findAncestorBuildById(project.id, buildWithoutAncestor.id); + expect(ancestor).toEqual(undefined); + + const implicitPriorAncestorBuild = await client.createBuild({ + ...buildA, + projectId: project.id, + branch: 'master', + runAt: new Date('2019-01-01').toISOString(), + }); + ancestor = await client.findAncestorBuildById(project.id, buildWithoutAncestor.id); + expect(ancestor).toEqual(implicitPriorAncestorBuild); + + const implicitFutureAncestorBuild = await client.createBuild({ + ...buildA, + projectId: project.id, + branch: 'master', + runAt: new Date('2019-09-02').toISOString(), + }); + + ancestor = await client.findAncestorBuildById(project.id, buildWithoutAncestor.id); + expect(ancestor).toEqual(implicitFutureAncestorBuild); + }); + }); + describe('/:projectId/builds/:buildId/runs', () => { const lhr = { lighthouseVersion: '4.1.0', diff --git a/packages/utils/src/api-client.js b/packages/utils/src/api-client.js index d9e779326..469651d41 100644 --- a/packages/utils/src/api-client.js +++ b/packages/utils/src/api-client.js @@ -205,6 +205,17 @@ class ApiClient { return this._convert404ToUndefined(this._get(`/v1/projects/${projectId}/builds/${buildId}`)); } + /** + * @param {string} projectId + * @param {string} buildId + * @return {Promise} + */ + async findAncestorBuildById(projectId, buildId) { + return this._convert404ToUndefined( + this._get(`/v1/projects/${projectId}/builds/${buildId}/ancestor`) + ); + } + /** * @param {string} projectId * @param {string} buildId