-
Couldn't load subscription status.
- Fork 13
feat: codefix handler to update suggestions with patch content #1343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
541f0ba
de62758
95eab58
76e77e6
7021d5c
8563fd7
d26e7e4
fc19a67
88be018
2af79e7
4bc3a88
583bf44
cab7534
e0a428c
832ee78
e134cd9
93cf961
83d6830
9652f0d
9414f4e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| /* | ||
| * Copyright 2025 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import { createHash } from 'crypto'; | ||
| import { | ||
| ok, badRequest, notFound, internalServerError, | ||
| } from '@adobe/spacecat-shared-http-utils'; | ||
| import { isNonEmptyArray } from '@adobe/spacecat-shared-utils'; | ||
| import { getObjectFromKey } from '../../utils/s3-utils.js'; | ||
|
|
||
| /** | ||
| * Generates a hash for the given URL and source combination. | ||
| * @param {string} url - The URL to hash | ||
| * @param {string} source - The source to hash | ||
| * @returns {string} - The generated hash (first 16 characters of MD5) | ||
| */ | ||
| function generateUrlSourceHash(url, source) { | ||
| const combined = `${url}_${source}`; | ||
| return createHash('md5').update(combined).digest('hex').substring(0, 16); | ||
| } | ||
|
|
||
| /** | ||
| * Reads code change report from S3 bucket | ||
| * @param {Object} s3Client - The S3 client instance | ||
| * @param {string} bucketName - The S3 bucket name | ||
| * @param {string} siteId - The site ID | ||
| * @param {string} url - The page URL | ||
| * @param {string} source - The source (optional) | ||
| * @param {string} type - The issue type (e.g., 'color-contrast') | ||
| * @param {Object} log - Logger instance | ||
| * @returns {Promise<Object|null>} - The report data or null if not found | ||
| */ | ||
| async function readCodeChangeReport(s3Client, bucketName, siteId, url, source, type, log) { | ||
| try { | ||
| const urlSourceHash = generateUrlSourceHash(url, source || ''); | ||
| const reportKey = `fixes/${siteId}/${urlSourceHash}/${type}/report.json`; | ||
|
|
||
| log.info(`Reading code change report from S3: ${reportKey}`); | ||
|
|
||
| const reportData = await getObjectFromKey(s3Client, bucketName, reportKey, log); | ||
|
|
||
| if (!reportData) { | ||
| log.warn(`No code change report found for key: ${reportKey}`); | ||
| return null; | ||
| } | ||
|
|
||
| log.info(`Successfully read code change report from S3: ${reportKey}`); | ||
| return reportData; | ||
| } catch (error) { | ||
| log.error(`Error reading code change report from S3: ${error.message}`, error); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Updates suggestions with code change data | ||
| * @param {Array} suggestions - Array of suggestion objects | ||
| * @param {string} url - The page URL to match | ||
| * @param {string} source - The source to match (optional) | ||
| * @param {string} ruleId - The WCAG rule ID to match | ||
| * @param {Object} reportData - The code change report data | ||
| * @param {Object} log - Logger instance | ||
| * @returns {Promise<Array>} - Array of updated suggestions | ||
| */ | ||
| async function updateSuggestionsWithCodeChange(suggestions, url, source, ruleId, reportData, log) { | ||
| const updatedSuggestions = []; | ||
|
|
||
| try { | ||
| const promises = []; | ||
| for (const suggestion of suggestions) { | ||
| const suggestionData = suggestion.getData(); | ||
|
|
||
| // Check if this suggestion matches the criteria | ||
| const suggestionUrl = suggestionData.url; | ||
| const suggestionSource = suggestionData.source; | ||
| const suggestionRuleId = suggestionData.issues[0]?.type; | ||
|
|
||
| if (suggestionUrl === url | ||
| && (!source || suggestionSource === source) | ||
| && suggestionRuleId === ruleId | ||
| && !!reportData.diff) { | ||
| log.info(`Updating suggestion ${suggestion.getId()} with code change data`); | ||
|
|
||
| // Update suggestion data with diff content and availability flag | ||
| const updatedData = { | ||
| ...suggestionData, | ||
| patchContent: reportData.diff, | ||
| isCodeChangeAvailable: true, | ||
| }; | ||
|
|
||
| suggestion.setData(updatedData); | ||
| suggestion.setUpdatedBy('system'); | ||
|
|
||
| promises.push(suggestion.save()); | ||
| updatedSuggestions.push(suggestion); | ||
|
|
||
| log.info(`Successfully updated suggestion ${suggestion.getId()}`); | ||
| } | ||
| } | ||
| await Promise.all(promises); | ||
| } catch (error) { | ||
| log.error(`Error updating suggestions with code change data: ${error.message}`, error); | ||
| throw error; | ||
| } | ||
|
|
||
| return updatedSuggestions; | ||
| } | ||
|
|
||
| /** | ||
| * AccessibilityCodeFixHandler - Updates suggestions with code changes from S3 | ||
| * | ||
| * Expected message format: | ||
| * { | ||
| * "siteId": "<site-id>", | ||
| * "type": "codefix:accessibility", | ||
| * "data": { | ||
| * "opportunityId": "<uuid>", | ||
| * "updates": [ | ||
| * { | ||
| * "url": "<page url>", | ||
| * "source": "<source>", // optional | ||
| * "type": ["color-contrast", "select-name"] | ||
| * } | ||
| * ] | ||
| * } | ||
| * } | ||
| * | ||
| * @param {Object} message - The SQS message | ||
| * @param {Object} context - The context object containing dataAccess, log, s3Client, etc. | ||
| * @returns {Promise<Response>} - HTTP response | ||
| */ | ||
| export default async function accessibilityCodeFixHandler(message, context) { | ||
| const { | ||
| log, dataAccess, s3Client, env, | ||
| } = context; | ||
| const { Opportunity } = dataAccess; | ||
| const { siteId, data } = message; | ||
|
|
||
| if (!data) { | ||
| log.error('AccessibilityCodeFixHandler: No data provided in message'); | ||
| return badRequest('No data provided in message'); | ||
| } | ||
|
|
||
| const { opportunityId, updates } = data; | ||
|
|
||
| if (!opportunityId) { | ||
| log.error('[AccessibilityCodeFixHandler] No opportunityId provided'); | ||
| return badRequest('No opportunityId provided'); | ||
| } | ||
|
|
||
| if (!isNonEmptyArray(updates)) { | ||
| log.error('[AccessibilityCodeFixHandler] No updates provided or updates is empty'); | ||
| return badRequest('No updates provided or updates is empty'); | ||
| } | ||
|
|
||
| log.info(`[AccessibilityCodeFixHandler] Processing message for siteId: ${siteId}, opportunityId: ${opportunityId}`); | ||
|
|
||
| try { | ||
| // Find the opportunity | ||
| const opportunity = await Opportunity.findById(opportunityId); | ||
|
|
||
| if (!opportunity) { | ||
| log.error(`[AccessibilityCodeFixHandler] Opportunity not found for ID: ${opportunityId}`); | ||
| return notFound('Opportunity not found'); | ||
| } | ||
|
|
||
| // Verify the opportunity belongs to the correct site | ||
| if (opportunity.getSiteId() !== siteId) { | ||
| const errorMsg = `[AccessibilityCodeFixHandler] Site ID mismatch. Expected: ${siteId}, Found: ${opportunity.getSiteId()}`; | ||
| log.error(errorMsg); | ||
| return badRequest('Site ID mismatch'); | ||
| } | ||
|
|
||
| // Get all suggestions for the opportunity | ||
| const suggestions = await opportunity.getSuggestions(); | ||
|
|
||
| if (!isNonEmptyArray(suggestions)) { | ||
| log.warn(`[AccessibilityCodeFixHandler] No suggestions found for opportunity: ${opportunityId}`); | ||
| return ok('No suggestions found for opportunity'); | ||
| } | ||
|
|
||
| const bucketName = env.S3_MYSTIQUE_BUCKET_NAME; | ||
|
|
||
| if (!bucketName) { | ||
| log.error('AccessibilityCodeFixHandler: S3_MYSTIQUE_BUCKET_NAME environment variable not set'); | ||
| return internalServerError('S3 bucket name not configured'); | ||
| } | ||
|
|
||
| let totalUpdatedSuggestions = 0; | ||
|
|
||
| // Process each update | ||
| await Promise.all(updates.map(async (update) => { | ||
| const { url, source, type: types } = update; | ||
|
|
||
| if (!url) { | ||
| log.warn('[AccessibilityCodeFixHandler] Skipping update without URL'); | ||
| return; | ||
| } | ||
|
|
||
| if (!isNonEmptyArray(types)) { | ||
| log.warn(`[AccessibilityCodeFixHandler] Skipping update for URL ${url} without types`); | ||
| return; | ||
| } | ||
|
|
||
| log.info(`[AccessibilityCodeFixHandler] Processing update for URL: ${url}, source: ${source || 'N/A'}, types: ${types.join(', ')}`); | ||
|
|
||
| // For each type in the update, try to read the code change report | ||
| await Promise.all(types.map(async (ruleId) => { | ||
| let reportData = await readCodeChangeReport( | ||
| s3Client, | ||
| bucketName, | ||
| siteId, | ||
| url, | ||
| source, | ||
| ruleId, | ||
| log, | ||
| ); | ||
|
|
||
| if (!reportData) { | ||
| log.warn(`[AccessibilityCodeFixHandler] No code change report found for URL: ${url}, source: ${source}, type: ${ruleId}`); | ||
| return; | ||
| } | ||
|
|
||
| reportData = JSON.parse(reportData); | ||
|
|
||
| // Update matching suggestions with the code change data | ||
| const updatedSuggestions = await updateSuggestionsWithCodeChange( | ||
| suggestions, | ||
| url, | ||
| source, | ||
| ruleId, | ||
| reportData, | ||
| log, | ||
| ); | ||
| totalUpdatedSuggestions += updatedSuggestions.length; | ||
| })); | ||
| })); | ||
|
|
||
| log.info(`[AccessibilityCodeFixHandler] Successfully processed all updates. Total suggestions updated: ${totalUpdatedSuggestions}`); | ||
| return ok(); | ||
| } catch (error) { | ||
| log.error(`[AccessibilityCodeFixHandler] Error processing message: ${error.message}`, error); | ||
| return internalServerError(`Error processing message: ${error.message}`); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -754,3 +754,89 @@ export async function sendRunImportMessage( | |
| ...(data && { data }), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Groups suggestions by URL, source, and issue type, then sends messages | ||
| * to the importer worker for code-fix generation | ||
| * | ||
| * @param {Object} opportunity - The opportunity object containing suggestions | ||
| * @param {string} auditId - The audit ID | ||
| * @param {Object} context - The context object containing log, sqs, env, and site | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function sendCodeFixMessagesToImporter(opportunity, auditId, context) { | ||
| const { | ||
| log, sqs, env, site, | ||
| } = context; | ||
|
|
||
| const siteId = opportunity.getSiteId(); | ||
| const baseUrl = site.getBaseURL(); | ||
| const opportunityType = opportunity.getType(); | ||
|
|
||
| try { | ||
| // Get all suggestions from the opportunity | ||
| const suggestions = await opportunity.getSuggestions(); | ||
| if (!suggestions || suggestions.length === 0) { | ||
| log.info(`[${opportunityType}] [Site Id: ${siteId}] No suggestions found for code-fix generation`); | ||
| return; | ||
| } | ||
|
|
||
| // Group suggestions by URL, source, and issueType | ||
| const groupedSuggestions = new Map(); | ||
|
|
||
| suggestions.forEach((suggestion) => { | ||
| const suggestionData = suggestion.getData(); | ||
| const { url, source = 'default', issues } = suggestionData; | ||
|
|
||
| // By design, data.issues will always have length 1 | ||
| if (issues && issues.length > 0) { | ||
| const issueType = issues[0].type; | ||
| const groupKey = `${url}|${source}|${issueType}`; | ||
| if (!groupedSuggestions.has(groupKey)) { | ||
| groupedSuggestions.set(groupKey, { | ||
| url, | ||
| source, | ||
| issueType, | ||
| suggestionIds: [], | ||
| }); | ||
| } | ||
|
|
||
| // Add the suggestion ID to the group | ||
| groupedSuggestions.get(groupKey).suggestionIds.push(suggestion.getId()); | ||
| } | ||
| }); | ||
|
|
||
| log.info(`[${opportunityType}] [Site Id: ${siteId}] Grouped suggestions into ${groupedSuggestions.size} groups for code-fix generation`); | ||
|
|
||
| const messagePromises = Array.from(groupedSuggestions.values()).map(async (group) => { | ||
| const message = { | ||
| type: 'code', | ||
| siteId, | ||
| forward: { | ||
| queue: env.QUEUE_SPACECAT_TO_MYSTIQUE, | ||
| type: `codefix:${opportunityType}`, | ||
| siteId, | ||
| auditId, | ||
| url: baseUrl, | ||
| deliveryType: site.getDeliveryType(), | ||
| data: { | ||
| opportunityId: opportunity.getId(), | ||
| suggestionIds: group.suggestionIds, | ||
| }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought the forward message has the structure There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No |
||
| }, | ||
| }; | ||
|
|
||
| try { | ||
| await sqs.sendMessage(env.IMPORT_WORKER_QUEUE_URL, message); | ||
| log.info(`[${opportunityType}] [Site Id: ${siteId}] Sent code-fix message to importer for URL: ${group.url}, source: ${group.source}, issueType: ${group.issueType}, suggestions: ${group.suggestionIds.length}`); | ||
| } catch (error) { | ||
| log.error(`[${opportunityType}] [Site Id: ${siteId}] Failed to send code-fix message for URL: ${group.url}, error: ${error.message}`); | ||
| } | ||
| }); | ||
|
|
||
| await Promise.all(messagePromises); | ||
| log.info(`[${opportunityType}] [Site Id: ${siteId}] Completed sending ${messagePromises.length} code-fix messages to importer`); | ||
| } catch (error) { | ||
| log.error(`[${opportunityType}] [Site Id: ${siteId}] Error in sendCodeFixMessagesToImporter: ${error.message}`); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.