Skip to content
Snippets Groups Projects
Verified Commit adf26a73 authored by Jordi Gómez's avatar Jordi Gómez
Browse files

feat: adding email notification when certian revalidation threshold is met

parent fd35a665
No related branches found
No related tags found
1 merge request!221feat: adding email notification when certian revalidation threshold is met
Pipeline #70095 failed
<?xml version="1.0"?>
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.eclipsefoundation</groupId>
<artifactId>git-eca</artifactId>
......@@ -25,8 +22,7 @@
<sonar.tests>src/test</sonar.tests>
<sonar.java.coveragePlugin>jacoco</sonar.java.coveragePlugin>
<sonar.dynamicAnalysis>reuseReports</sonar.dynamicAnalysis>
<sonar.coverage.jacoco.xmlReportPaths>
${project.basedir}/target/jacoco-report/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
<sonar.coverage.jacoco.xmlReportPaths>${project.basedir}/target/jacoco-report/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
<sonar.junit.reportPath>${project.build.directory}/surefire-reports</sonar.junit.reportPath>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.organization>eclipse-foundation-it</sonar.organization>
......@@ -38,10 +34,8 @@
<id>eclipsefdn</id>
<url>https://repo.eclipse.org/content/repositories/eclipsefdn/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
......@@ -80,6 +74,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
<!-- Annotation preprocessors - reduce all of the boiler plate -->
<dependency>
......@@ -179,8 +177,7 @@
<configuration>
<skipTests>false</skipTests>
<systemPropertyVariables>
<java.util.logging.manager>
org.jboss.logmanager.LogManager</java.util.logging.manager>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
......
/**
* Copyright (c) 2025 Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipsefoundation.git.eca.service;
import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
/**
* A service for sending email notifications related to GitHub webhook events.
* This interface defines methods for sending different types of email alerts
* to appropriate parties based on webhook tracking data.
*/
public interface MailerService {
/**
* Sends a revalidation alert for a tracked GitHub webhook event.
* This method is responsible for notifying relevant parties about the need to revalidate a webhook tracking event.
*
* @param tracking the GitHub webhook tracking information containing details about the event that needs revalidation
*/
void sendRevalidationAlert(GithubWebhookTracking tracking, Integer threshold);
}
/**
* Copyright (c) 2025 Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* Author: Jordi Gómez <jordi.gomez@eclipse-foundation.org>
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipsefoundation.git.eca.service.impl;
import java.util.List;
import java.util.Optional;
import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
import org.eclipsefoundation.git.eca.service.MailerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import io.smallrye.config.ConfigMapping;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class DefaultMailerService implements MailerService {
public static final Logger LOGGER = LoggerFactory.getLogger(DefaultMailerService.class);
private final EclipseMailerConfig config;
private final Mailer mailer;
@Location("emails/revalidation-alert")
Template revalidationAlertTemplate;
public DefaultMailerService(EclipseMailerConfig config, Mailer mailer) {
this.config = config;
this.mailer = mailer;
}
@Override
public void sendRevalidationAlert(GithubWebhookTracking tracking, Integer threshold) {
var revalidationAlertConfig = config.revalidationAlert();
if (revalidationAlertConfig.to().isEmpty()) {
LOGGER.warn("No recipients configured for revalidation alert. Skipping email notification.");
return;
}
String subject = "GitHub Webhook Revalidation Alert";
Mail messageBuilder = new Mail();
messageBuilder.setSubject(subject);
messageBuilder.setText(revalidationAlertTemplate.data("attempts", threshold)
.data("requestId", tracking.getId())
.data("repository", tracking.getRepositoryFullName())
.data("pullRequest", tracking.getPullRequestNumber())
.data("lastState", tracking.getLastKnownState())
.data("lastUpdated", tracking.getLastUpdated().toString())
.render());
messageBuilder.addTo(revalidationAlertConfig.to().toArray(String[]::new));
revalidationAlertConfig.authorMessage().replyTo()
.ifPresent(messageBuilder::addReplyTo);
revalidationAlertConfig.authorMessage().bcc()
.ifPresent(bcc -> messageBuilder.addBcc(bcc.toArray(String[]::new)));
mailer.send(messageBuilder);
LOGGER.info("Revalidation alert sent to: {}", revalidationAlertConfig.to());
}
/**
* Represents configuration for the default mailer service.
*
* @author Martin Lowe
*
*/
@ConfigMapping(prefix = "eclipse.mailer")
public interface EclipseMailerConfig {
public RevalidationAlert revalidationAlert();
/**
* This interface defines the contract for specifying recipients and message
* configuration when sending revalidation alerts within the ECA validation
* process.
*/
public interface RevalidationAlert {
public List<String> to();
public MessageConfiguration authorMessage();
}
public interface MessageConfiguration {
public Optional<String> replyTo();
public Optional<List<String>> bcc();
}
}
}
......@@ -14,11 +14,13 @@ package org.eclipsefoundation.git.eca.tasks;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
import org.eclipsefoundation.git.eca.helper.GithubHelper;
import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
import org.eclipsefoundation.git.eca.service.MailerService;
import org.eclipsefoundation.http.model.FlatRequestWrapper;
import org.eclipsefoundation.http.model.RequestWrapper;
import org.eclipsefoundation.http.namespace.DefaultUrlParameterNames;
......@@ -40,115 +42,176 @@ import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
/**
* Scheduled regular task that will interact with the backend persistence to look for requests that are in a
* failed/unvalidated state after an error while processing that could not be recovered. These requests will be
* reprocessed using the same logic as the standard validation, updating the timestamp on completion and either setting
* the revalidation flag to false or incrementing the number of repeated revalidations needed for the request for
* Scheduled regular task that will interact with the backend persistence to
* look for requests that are in a
* failed/unvalidated state after an error while processing that could not be
* recovered. These requests will be
* reprocessed using the same logic as the standard validation, updating the
* timestamp on completion and either setting
* the revalidation flag to false or incrementing the number of repeated
* revalidations needed for the request for
* tracking, depending on the succcess of the revalidation.
*/
@ApplicationScoped
public class GithubRevalidationQueue {
private static final Logger LOGGER = LoggerFactory.getLogger(GithubRevalidationQueue.class);
// full repo name should be 2 parts, org and actual repo name
private static final int FULL_REPO_NAME_PARTS = 2;
@ConfigProperty(name = "eclipse.git-eca.tasks.gh-revalidation.enabled", defaultValue = "true")
Instance<Boolean> isEnabled;
@Inject
PersistenceDao dao;
@Inject
FilterService filters;
@Inject
GithubHelper validationHelper;
@PostConstruct
void init() {
// indicate to log whether enabled to reduce log spam
LOGGER.info("Github revalidation queue task is{} enabled.", Boolean.TRUE.equals(isEnabled.get()) ? "" : " not");
private static final Logger LOGGER = LoggerFactory.getLogger(GithubRevalidationQueue.class);
// full repo name should be 2 parts, org and actual repo name
private static final int FULL_REPO_NAME_PARTS = 2;
@ConfigProperty(name = "eclipse.git-eca.tasks.gh-revalidation.enabled", defaultValue = "true")
Instance<Boolean> isEnabled;
@ConfigProperty(name = "eclipse.git-eca.tasks.gh-revalidation.notification-threshold", defaultValue = "3")
Instance<Integer> revalidationThreshold;
@Inject
PersistenceDao dao;
@Inject
FilterService filters;
@Inject
GithubHelper validationHelper;
@Inject
MailerService mailerService;
@PostConstruct
void init() {
// indicate to log whether enabled to reduce log spam
LOGGER.info("Github revalidation queue task is{} enabled.", Boolean.TRUE.equals(isEnabled.get()) ? "" : " not");
}
/**
* Every 5s, this method will attempt to load a Github webhook validation
* request that has the needs revalidation flag
* set to true. This will retrieve the oldest request in queue and will attempt
* to revalidate it.
*/
@Scheduled(every = "${eclipse.git-eca.tasks.gh-revalidation.frequency:60s}")
@ActivateRequestContext
public void revalidate() {
// if not enabled, don't process any potentially OOD records
if (!Boolean.TRUE.equals(isEnabled.get())) {
return;
}
/**
* Every 5s, this method will attempt to load a Github webhook validation request that has the needs revalidation flag
* set to true. This will retrieve the oldest request in queue and will attempt to revalidate it.
*/
@Scheduled(every = "${eclipse.git-eca.tasks.gh-revalidation.frequency:60s}")
@ActivateRequestContext
public void revalidate() {
// if not enabled, don't process any potentially OOD records
if (!Boolean.TRUE.equals(isEnabled.get())) {
return;
}
// set up params for looking up the top of the revalidation queue
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add(GitEcaParameterNames.NEEDS_REVALIDATION_RAW, "true");
params.add(PersistenceUrlParameterNames.SORT.getName(), "lastUpdated");
params.add(DefaultUrlParameterNames.PAGESIZE.getName(), "1");
// build the request and query to lookup the longest standing request that needs revalidation
RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/eca/revalidation-queue"));
RDBMSQuery<GithubWebhookTracking> trackingQuery = new RDBMSQuery<>(wrap, filters.get(GithubWebhookTracking.class), params);
List<GithubWebhookTracking> oldestRevalidation = dao.get(trackingQuery);
if (oldestRevalidation.isEmpty()) {
LOGGER.debug("No queued revalidation requests found");
} else {
reprocessRequest(oldestRevalidation.get(0), wrap);
}
// set up params for looking up the top of the revalidation queue
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add(GitEcaParameterNames.NEEDS_REVALIDATION_RAW, "true");
params.add(PersistenceUrlParameterNames.SORT.getName(), "lastUpdated");
params.add(DefaultUrlParameterNames.PAGESIZE.getName(), "1");
// build the request and query to lookup the longest standing request that needs
// revalidation
RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/eca/revalidation-queue"));
RDBMSQuery<GithubWebhookTracking> trackingQuery = new RDBMSQuery<>(wrap, filters.get(GithubWebhookTracking.class),
params);
List<GithubWebhookTracking> oldestRevalidation = dao.get(trackingQuery);
if (oldestRevalidation.isEmpty()) {
LOGGER.debug("No queued revalidation requests found");
} else {
reprocessRequest(oldestRevalidation.get(0), wrap);
}
}
/**
* Reprocess the given record, attempting to run the ECA validation logic again.
* If it passes, the revalidation flag is
* set to false and the time code is updated. If the processing fails again, the
* failure count gets incremented and the
* updated time is set so that another entry can be updated, as to not block on
* potentially broken records.
*
* @param requestToRevalidate the webhook tracking request to attempt to
* revalidate
* @param wrap the current stubbed request wrapper used for
* queries.
*/
private void reprocessRequest(GithubWebhookTracking requestToRevalidate, RequestWrapper wrap) {
LOGGER
.debug("Attempting revalidation of request w/ ID {}, in repo {}#{}", requestToRevalidate.getId(),
requestToRevalidate.getRepositoryFullName(), requestToRevalidate.getPullRequestNumber());
// update the number of times this status has revalidated (tracking)
requestToRevalidate
.setManualRevalidationCount(requestToRevalidate.getManualRevalidationCount() == null ? 1
: requestToRevalidate.getManualRevalidationCount() + 1);
// Check if notification threshold is reached and send email if needed
checkRevalidationThreshold(requestToRevalidate);
// wrap in try-catch to avoid errors from stopping the record updates
try {
// split the full repo name into the org and repo name
String[] repoFullNameParts = requestToRevalidate.getRepositoryFullName().split("/");
if (repoFullNameParts.length != FULL_REPO_NAME_PARTS) {
throw new IllegalStateException("Record with ID '" + Long.toString(requestToRevalidate.getId())
+ "' is in an invalid state (repository full name is not valid)");
}
// run the validation and then check if it succeeded. Use the forced flag since
// we want to try even if there are no
// changes
validationHelper
.validateIncomingRequest(wrap, repoFullNameParts[0], repoFullNameParts[1],
requestToRevalidate.getPullRequestNumber(),
true);
// if we have gotten here, then the validation has completed and can be removed
// from queue
requestToRevalidate.setNeedsRevalidation(false);
LOGGER.debug("Sucessfully revalidated request w/ ID {}", requestToRevalidate.getId());
} catch (RuntimeException e) {
// log the message so we can see what happened
LOGGER.error("Error while revalidating request w/ ID {}", requestToRevalidate.getId(), e);
} finally {
// whether successful or failed, update the updated time field
requestToRevalidate.setLastUpdated(DateTimeHelper.now());
// if in a closed state, we shouldn't try to revalidate again as it will never
// be valid
// if a PR is reopened, it should create a new event and validate anyways
if ("closed".equalsIgnoreCase(requestToRevalidate.getLastKnownState())) {
LOGGER.debug("Tracking request {} set to not revalidate as it was closed", requestToRevalidate.getId());
requestToRevalidate.setNeedsRevalidation(false);
}
// push the update with the potentially updated validation flag or error count
dao.add(new RDBMSQuery<>(wrap, filters.get(GithubWebhookTracking.class)), Arrays.asList(requestToRevalidate));
}
}
/**
* Checks if the manual revalidation count has reached the configured threshold
* and sends an email notification if it has.
*
* @param tracking the webhook tracking object
*/
private void checkRevalidationThreshold(GithubWebhookTracking tracking) {
Integer threshold = revalidationThreshold.get();
// If manual revalidation count is null or less than threshold, do nothing
if (Objects.isNull(tracking.getManualRevalidationCount()) ||
tracking.getManualRevalidationCount() < threshold) {
return;
}
/**
* Reprocess the given record, attempting to run the ECA validation logic again. If it passes, the revalidation flag is
* set to false and the time code is updated. If the processing fails again, the failure count gets incremented and the
* updated time is set so that another entry can be updated, as to not block on potentially broken records.
*
* @param requestToRevalidate the webhook tracking request to attempt to revalidate
* @param wrap the current stubbed request wrapper used for queries.
*/
private void reprocessRequest(GithubWebhookTracking requestToRevalidate, RequestWrapper wrap) {
LOGGER
.debug("Attempting revalidation of request w/ ID {}, in repo {}#{}", requestToRevalidate.getId(),
requestToRevalidate.getRepositoryFullName(), requestToRevalidate.getPullRequestNumber());
// update the number of times this status has revalidated (tracking)
requestToRevalidate
.setManualRevalidationCount(requestToRevalidate.getManualRevalidationCount() == null ? 1
: requestToRevalidate.getManualRevalidationCount() + 1);
// wrap in try-catch to avoid errors from stopping the record updates
try {
// split the full repo name into the org and repo name
String[] repoFullNameParts = requestToRevalidate.getRepositoryFullName().split("/");
if (repoFullNameParts.length != FULL_REPO_NAME_PARTS) {
throw new IllegalStateException("Record with ID '" + Long.toString(requestToRevalidate.getId())
+ "' is in an invalid state (repository full name is not valid)");
}
// run the validation and then check if it succeeded. Use the forced flag since we want to try even if there are no
// changes
validationHelper
.validateIncomingRequest(wrap, repoFullNameParts[0], repoFullNameParts[1], requestToRevalidate.getPullRequestNumber(),
true);
// if we have gotten here, then the validation has completed and can be removed from queue
requestToRevalidate.setNeedsRevalidation(false);
LOGGER.debug("Sucessfully revalidated request w/ ID {}", requestToRevalidate.getId());
} catch (RuntimeException e) {
// log the message so we can see what happened
LOGGER.error("Error while revalidating request w/ ID {}", requestToRevalidate.getId(), e);
} finally {
// whether successful or failed, update the updated time field
requestToRevalidate.setLastUpdated(DateTimeHelper.now());
// if in a closed state, we shouldn't try to revalidate again as it will never be valid
// if a PR is reopened, it should create a new event and validate anyways
if ("closed".equalsIgnoreCase(requestToRevalidate.getLastKnownState())) {
LOGGER.debug("Tracking request {} set to not revalidate as it was closed", requestToRevalidate.getId());
requestToRevalidate.setNeedsRevalidation(false);
}
// push the update with the potentially updated validation flag or error count
dao.add(new RDBMSQuery<>(wrap, filters.get(GithubWebhookTracking.class)), Arrays.asList(requestToRevalidate));
}
// Check if count equals threshold (to send notification only once when threshold is reached)
if (tracking.getManualRevalidationCount() == threshold) {
sendRevalidationAlert(tracking);
}
}
/**
* Sends an email alert about a webhook that has reached the revalidation
* threshold.
*
* @param tracking the webhook tracking object
*/
private void sendRevalidationAlert(GithubWebhookTracking tracking) {
try {
mailerService.sendRevalidationAlert(tracking, revalidationThreshold.get());
LOGGER.info("Sent revalidation alert email for request ID {}", tracking.getId());
} catch (RuntimeException e) {
LOGGER.error("Failed to send revalidation alert email for request ID {}", tracking.getId(), e);
}
}
}
A GitHub webhook has reached the revalidation threshold of {attempts} attempts.
Details:
- Request ID: {requestId}
- Repository: {repository}
- Pull Request: #{pullRequest}
- Last State: {lastState}
- Last Updated: {lastUpdated}
Please investigate this issue as it may indicate a persistent problem.
\ No newline at end of file
......@@ -23,6 +23,14 @@ quarkus.keycloak.devservices.enabled=false
quarkus.oidc-client.enabled=false
smallrye.jwt.sign.key.location=test.pem
# Quarkus Mailer
quarkus.mailer.auth-methods=DIGEST-MD5 CRAM-SHA256 CRAM-SHA1 CRAM-MD5 PLAIN LOGIN
quarkus.mailer.from=membership.coordination@eclipse-foundation.org
# Uses gmail by default, can be overridden
quarkus.mailer.host=smtp.gmail.com
quarkus.mailer.port=465
quarkus.mailer.ssl=true
# hCaptcha test key and secret
eclipse.hcaptcha.site-key=20000000-ffff-ffff-ffff-000000000002
eclipse.hcaptcha.secret=0x0000000000000000000000000000000000000000
......@@ -35,9 +43,10 @@ eclipse.git-eca.mail.allow-list=noreply@github.com
eclipse.git-eca.reports.access-key=samplekey
eclipse.git-eca.tasks.gh-revalidation.enabled=false
eclipse.git-eca.tasks.gh-installation.enabled=false
eclipse.git-eca.tasks.gh-revalidation.notification-threshold=100
## Misc
eclipse.gitlab.access-token=token_val
## Disable private project scan in test mode
eclipse.scheduled.private-project.enabled=false
\ No newline at end of file
eclipse.scheduled.private-project.enabled=false
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment