diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index 2f9d78af6..bff88e655 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -28,7 +28,7 @@ import { import { Suggestion as SuggestionModel } from '@adobe/spacecat-shared-data-access'; import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client'; -import { SuggestionDto, SUGGESTION_VIEWS } from '../dto/suggestion.js'; +import { SuggestionDto, SUGGESTION_VIEWS, extractCodePatchMap } from '../dto/suggestion.js'; import { FixDto } from '../dto/fix.js'; import { sendAutofixMessage, getIMSPromiseToken, ErrorWithStatusCode } from '../support/utils.js'; import AccessControlUtil from '../support/access-control-util.js'; @@ -312,9 +312,16 @@ function SuggestionsController(ctx, sqs, env) { return notFound('Opportunity not found'); } } + const dtoView = view === 'full-dedupe' ? 'full' : view; const suggestions = suggestionEntities.map( - (sugg) => SuggestionDto.toJSON(sugg, view, opportunity), + (sugg) => SuggestionDto.toJSON(sugg, dtoView, opportunity), ); + + if (view === 'full-dedupe') { + const codePatchMap = extractCodePatchMap(suggestions); + return ok({ suggestions, ...(codePatchMap && { codePatchMap }) }); + } + return ok(suggestions); }; @@ -371,9 +378,11 @@ function SuggestionsController(ctx, sqs, env) { return notFound('Opportunity not found'); } } + const dtoView = view === 'full-dedupe' ? 'full' : view; const suggestions = suggestionEntities.map( - (sugg) => SuggestionDto.toJSON(sugg, view, opportunity), + (sugg) => SuggestionDto.toJSON(sugg, dtoView, opportunity), ); + const codePatchMap = view === 'full-dedupe' ? extractCodePatchMap(suggestions) : null; return ok({ suggestions, pagination: { @@ -381,6 +390,7 @@ function SuggestionsController(ctx, sqs, env) { cursor: newCursor ?? null, hasMore: !!newCursor, }, + ...(codePatchMap && { codePatchMap }), }); }; diff --git a/src/dto/suggestion.js b/src/dto/suggestion.js index 8b402f9ef..5d5493e4f 100644 --- a/src/dto/suggestion.js +++ b/src/dto/suggestion.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { createHash } from 'crypto'; import { buildAggregationKeyFromSuggestion } from '@adobe/spacecat-shared-utils'; import { Suggestion } from '@adobe/spacecat-shared-data-access'; @@ -17,7 +18,7 @@ import { Suggestion } from '@adobe/spacecat-shared-data-access'; * Valid projection views for suggestions. * @type {string[]} */ -export const SUGGESTION_VIEWS = ['minimal', 'summary', 'full']; +export const SUGGESTION_VIEWS = ['minimal', 'summary', 'full', 'full-dedupe']; /** * Extracts minimal data fields from suggestion data using schema-driven projection. @@ -112,3 +113,30 @@ export const SuggestionDto = { }; }, }; + +/** + * Extracts patchContent from serialized suggestions into a deduplicated map. + * Each suggestion's data.patchContent is replaced with a short content hash + * that serves as a key into the returned map. + * + * @param {object[]} suggestions - Array of serialized suggestion JSON objects (mutated in place). + * @returns {object|null} Map of hash → patchContent, or null if no patches found. + */ +export function extractCodePatchMap(suggestions) { + const codePatchMap = {}; + let hasPatches = false; + + for (const suggestion of suggestions) { + const content = suggestion.data?.patchContent; + if (content != null) { + const hash = createHash('sha256').update(String(content)).digest('hex').substring(0, 12); + if (!codePatchMap[hash]) { + codePatchMap[hash] = content; + } + suggestion.data.patchContent = hash; + hasPatches = true; + } + } + + return hasPatches ? codePatchMap : null; +} diff --git a/test/controllers/suggestions.test.js b/test/controllers/suggestions.test.js index 6e20ad276..5d9ee9f7c 100644 --- a/test/controllers/suggestions.test.js +++ b/test/controllers/suggestions.test.js @@ -585,7 +585,7 @@ describe('Suggestions Controller', () => { }); expect(response.status).to.equal(400); const error = await response.json(); - expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full'); + expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full, full-dedupe'); }); it('gets all suggestions for an opportunity with minimal view', async () => { @@ -806,7 +806,7 @@ describe('Suggestions Controller', () => { }); expect(response.status).to.equal(400); const error = await response.json(); - expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full'); + expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full, full-dedupe'); }); it('gets paged suggestions for an opportunity successfully', async () => { @@ -1067,7 +1067,79 @@ describe('Suggestions Controller', () => { }); expect(response.status).to.equal(400); const error = await response.json(); - expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full'); + expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full, full-dedupe'); + }); + + it('getByStatus with full-dedupe view returns object with suggestions and codePatchMap', async () => { + const patchContent = 'diff --git a/file.js\n-old\n+new'; + const suggWithPatch = { + ...suggs[0], + data: { ...suggs[0].data, patchContent }, + }; + mockSuggestion.allByOpportunityIdAndStatus.resolves([mockSuggestionEntity(suggWithPatch)]); + + const response = await suggestionsController.getByStatus({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + status: 'NEW', + }, + data: { view: 'full-dedupe' }, + ...context, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body).to.have.property('suggestions').that.is.an('array'); + expect(body).to.have.property('codePatchMap').that.is.an('object'); + const hashKey = body.suggestions[0].data.patchContent; + expect(hashKey).to.be.a('string').with.lengthOf(12); + expect(body.codePatchMap[hashKey]).to.equal(patchContent); + }); + + it('getByStatus with full-dedupe view deduplicates identical patches', async () => { + const sharedPatch = 'diff --git a/shared.js\n-a\n+b'; + const uniquePatch = 'diff --git a/unique.js\n-x\n+y'; + const entities = [ + mockSuggestionEntity({ ...suggs[0], data: { patchContent: sharedPatch } }), + mockSuggestionEntity({ ...suggs[1], data: { patchContent: uniquePatch } }), + mockSuggestionEntity({ ...suggs[2], data: { patchContent: sharedPatch } }), + ]; + mockSuggestion.allByOpportunityIdAndStatus.resolves(entities); + + const response = await suggestionsController.getByStatus({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + status: 'NEW', + }, + data: { view: 'full-dedupe' }, + ...context, + }); + + const body = await response.json(); + expect(Object.keys(body.codePatchMap)).to.have.lengthOf(2); + expect(body.suggestions[0].data.patchContent) + .to.equal(body.suggestions[2].data.patchContent); + }); + + it('getByStatus with full-dedupe view omits codePatchMap when no patches exist', async () => { + mockSuggestion.allByOpportunityIdAndStatus.resolves([mockSuggestionEntity(suggs[0])]); + + const response = await suggestionsController.getByStatus({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + status: 'NEW', + }, + data: { view: 'full-dedupe' }, + ...context, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body).to.have.property('suggestions').that.is.an('array'); + expect(body).to.not.have.property('codePatchMap'); }); it('gets all suggestions for a site does not exist', async () => { @@ -1235,7 +1307,71 @@ describe('Suggestions Controller', () => { }); expect(response.status).to.equal(400); const error = await response.json(); - expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full'); + expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full, full-dedupe'); + }); + + it('getByStatusPaged with full-dedupe view includes codePatchMap', async () => { + const patchContent = 'diff --git a/file.js\n-old\n+new'; + const suggWithPatch = { + ...suggs[0], + data: { ...suggs[0].data, patchContent }, + }; + mockSuggestion.allByOpportunityIdAndStatus.callsFake((opptyId, status, options) => { + if (options) { + return Promise.resolve({ + data: [mockSuggestionEntity(suggWithPatch)], + cursor: undefined, + }); + } + return Promise.resolve([mockSuggestionEntity(suggWithPatch)]); + }); + + const response = await suggestionsController.getByStatusPaged({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + status: 'NEW', + }, + data: { view: 'full-dedupe' }, + ...context, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body).to.have.property('suggestions').that.is.an('array'); + expect(body).to.have.property('pagination'); + expect(body).to.have.property('codePatchMap').that.is.an('object'); + const hashKey = body.suggestions[0].data.patchContent; + expect(hashKey).to.be.a('string').with.lengthOf(12); + expect(body.codePatchMap[hashKey]).to.equal(patchContent); + }); + + it('getByStatusPaged with full-dedupe view omits codePatchMap when no patches', async () => { + mockSuggestion.allByOpportunityIdAndStatus.callsFake((opptyId, status, options) => { + if (options) { + return Promise.resolve({ + data: [mockSuggestionEntity(suggs[0])], + cursor: undefined, + }); + } + return Promise.resolve([mockSuggestionEntity(suggs[0])]); + }); + + const response = await suggestionsController.getByStatusPaged({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + status: 'NEW', + }, + data: { view: 'full-dedupe' }, + ...context, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body).to.have.property('suggestions'); + expect(body).to.have.property('pagination'); + expect(body).to.not.have.property('codePatchMap'); }); it('gets paged suggestions by status successfully', async () => { @@ -1524,7 +1660,7 @@ describe('Suggestions Controller', () => { }); expect(response.status).to.equal(400); const error = await response.json(); - expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full'); + expect(error).to.have.property('message', 'Invalid view. Must be one of: minimal, summary, full, full-dedupe'); }); it('gets suggestion by ID returns not found if suggestion is not found', async () => { diff --git a/test/dto/suggestion.test.js b/test/dto/suggestion.test.js index df308b09b..c5325cf32 100644 --- a/test/dto/suggestion.test.js +++ b/test/dto/suggestion.test.js @@ -14,7 +14,8 @@ import { expect } from 'chai'; -import { SuggestionDto, SUGGESTION_VIEWS } from '../../src/dto/suggestion.js'; +import { createHash } from 'crypto'; +import { SuggestionDto, SUGGESTION_VIEWS, extractCodePatchMap } from '../../src/dto/suggestion.js'; describe('Suggestion DTO', () => { const createMockSuggestion = (dataOverrides = {}) => ({ @@ -32,7 +33,7 @@ describe('Suggestion DTO', () => { describe('SUGGESTION_VIEWS', () => { it('exports valid view options', () => { - expect(SUGGESTION_VIEWS).to.deep.equal(['minimal', 'summary', 'full']); + expect(SUGGESTION_VIEWS).to.deep.equal(['minimal', 'summary', 'full', 'full-dedupe']); }); }); @@ -552,4 +553,107 @@ describe('Suggestion DTO', () => { }); }); }); + + describe('extractCodePatchMap', () => { + const expectedHash = (content) => createHash('sha256') + .update(String(content)) + .digest('hex') + .substring(0, 12); + + it('returns null when no suggestions have patchContent', () => { + const suggestions = [ + { id: '1', data: { url: 'https://example.com' } }, + { id: '2', data: { info: 'no patch' } }, + ]; + + const result = extractCodePatchMap(suggestions); + + expect(result).to.be.null; + }); + + it('returns null for empty array', () => { + expect(extractCodePatchMap([])).to.be.null; + }); + + it('returns null when suggestions have no data', () => { + const suggestions = [ + { id: '1' }, + { id: '2', data: null }, + ]; + + expect(extractCodePatchMap(suggestions)).to.be.null; + }); + + it('extracts patchContent into map and replaces with hash', () => { + const patchA = 'diff --git a/file.js\n- old\n+ new'; + const suggestions = [ + { id: '1', data: { url: 'https://example.com', patchContent: patchA } }, + ]; + + const result = extractCodePatchMap(suggestions); + + const hash = expectedHash(patchA); + expect(result).to.deep.equal({ [hash]: patchA }); + expect(suggestions[0].data.patchContent).to.equal(hash); + }); + + it('deduplicates identical patchContent across suggestions', () => { + const sharedPatch = 'diff --git a/shared.js\n- old\n+ new'; + const uniquePatch = 'diff --git a/unique.js\n- x\n+ y'; + const suggestions = [ + { id: '1', data: { patchContent: sharedPatch } }, + { id: '2', data: { patchContent: uniquePatch } }, + { id: '3', data: { patchContent: sharedPatch } }, + ]; + + const result = extractCodePatchMap(suggestions); + + expect(Object.keys(result)).to.have.lengthOf(2); + const sharedHash = expectedHash(sharedPatch); + const uniqueHash = expectedHash(uniquePatch); + expect(result[sharedHash]).to.equal(sharedPatch); + expect(result[uniqueHash]).to.equal(uniquePatch); + expect(suggestions[0].data.patchContent).to.equal(sharedHash); + expect(suggestions[1].data.patchContent).to.equal(uniqueHash); + expect(suggestions[2].data.patchContent).to.equal(sharedHash); + }); + + it('handles mix of suggestions with and without patchContent', () => { + const patch = 'diff --git a/file.js\n-a\n+b'; + const suggestions = [ + { id: '1', data: { url: 'https://example.com' } }, + { id: '2', data: { patchContent: patch, url: 'https://example.com/page' } }, + { id: '3', data: { info: 'no patch here' } }, + ]; + + const result = extractCodePatchMap(suggestions); + + const hash = expectedHash(patch); + expect(result).to.deep.equal({ [hash]: patch }); + expect(suggestions[0].data).to.not.have.property('patchContent'); + expect(suggestions[1].data.patchContent).to.equal(hash); + expect(suggestions[2].data).to.not.have.property('patchContent'); + }); + + it('preserves other data fields alongside replaced patchContent', () => { + const patch = 'some diff content'; + const suggestions = [ + { + id: '1', + data: { + url: 'https://example.com', + patchContent: patch, + info: 'keep this', + }, + }, + ]; + + extractCodePatchMap(suggestions); + + expect(suggestions[0].data.url).to.equal('https://example.com'); + expect(suggestions[0].data.info).to.equal('keep this'); + expect(suggestions[0].data.patchContent).to.be.a('string'); + expect(suggestions[0].data.patchContent).to.have.lengthOf(12); + }); + }); });