Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -371,16 +378,19 @@ 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: {
limit,
cursor: newCursor ?? null,
hasMore: !!newCursor,
},
...(codePatchMap && { codePatchMap }),
});
};

Expand Down
30 changes: 29 additions & 1 deletion src/dto/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
* 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';

/**
* 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.
Expand Down Expand Up @@ -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;
}
146 changes: 141 additions & 5 deletions test/controllers/suggestions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading