From fbd174bcedaa1db0a38de62246fcb42a30341690 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Tue, 22 Apr 2025 16:36:30 +0200
Subject: [PATCH 01/14] feat: adding base support for sending messages when a
 PR validation fails

---
 .../git/eca/api/GithubAPI.java                | 184 ++--
 .../git/eca/helper/GithubHelper.java          | 945 +++++++++---------
 .../git/eca/model/ValidationResponse.java     |   4 +-
 .../git/eca/helper/GithubHelperTest.java      | 133 +++
 .../git/eca/test/api/MockGithubAPI.java       | 143 +--
 5 files changed, 808 insertions(+), 601 deletions(-)
 create mode 100644 src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index 17f7229b..bced49eb 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -1,9 +1,8 @@
 /**
  * Copyright (c) 2022 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/
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
  *
@@ -42,88 +41,111 @@ import jakarta.ws.rs.core.Response;
 @Produces("application/json")
 public interface GithubAPI {
 
-    /**
-     * Retrieves information about a certain pull request in a repo if it exists
-     * 
-     * @param bearer authorization header value, access token for application with access to repo
-     * @param apiVersion the version of the GH API to target when making the request
-     * @param repoFull the full repo name that is being targeted
-     * @param pullNumber the pull request number
-     * @return information about the given pull request if it exists.
-     */
-    @GET
-    @Path("repos/{org}/{repo}/pulls/{pullNumber}")
-    public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
-            @PathParam("pullNumber") int pullNumber);
+  /**
+   * Retrieves information about a certain pull request in a repo if it exists
+   * 
+   * @param bearer authorization header value, access token for application with access to repo
+   * @param apiVersion the version of the GH API to target when making the request
+   * @param repoFull the full repo name that is being targeted
+   * @param pullNumber the pull request number
+   * @return information about the given pull request if it exists.
+   */
+  @GET
+  @Path("repos/{org}/{repo}/pulls/{pullNumber}")
+  public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
+      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber);
 
-    /**
-     * Retrieves a list of commits related to the given pull request.
-     * 
-     * @param bearer authorization header value, access token for application with access to repo
-     * @param apiVersion the version of the GH API to target when making the request
-     * @param repo the repo name that is being targeted
-     * @param pullNumber the pull request number
-     * @return list of commits associated with the pull request, wrapped in a jax-rs response
-     */
-    @GET
-    @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits")
-    public RestResponse<List<GithubCommit>> getCommits(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
-            @PathParam("pullNumber") int pullNumber);
+  /**
+   * Retrieves a list of commits related to the given pull request.
+   * 
+   * @param bearer authorization header value, access token for application with access to repo
+   * @param apiVersion the version of the GH API to target when making the request
+   * @param repo the repo name that is being targeted
+   * @param pullNumber the pull request number
+   * @return list of commits associated with the pull request, wrapped in a jax-rs response
+   */
+  @GET
+  @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits")
+  public RestResponse<List<GithubCommit>> getCommits(
+      @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
+      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber);
 
-    /**
-     * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha.
-     * 
-     * @param bearer authorization header value, access token for application with access to repo
-     * @param apiVersion the version of the GH API to target when making the request
-     * @param organization the organization that owns the targeted repo
-     * @param repo the repo name that is being targeted
-     * @param prHeadSha the head SHA for the request
-     * @param commitStatusUpdate the status body sent with the request
-     * @return JAX-RS response to check for success or failure based on status code.
-     */
-    @POST
-    @Path("repos/{org}/{repo}/statuses/{prHeadSha}")
-    public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
-            @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate);
+  /**
+   * Posts an update to the Github API, using an access token to update a given pull requests commit
+   * status, targeted using the head sha.
+   * 
+   * @param bearer authorization header value, access token for application with access to repo
+   * @param apiVersion the version of the GH API to target when making the request
+   * @param organization the organization that owns the targeted repo
+   * @param repo the repo name that is being targeted
+   * @param prHeadSha the head SHA for the request
+   * @param commitStatusUpdate the status body sent with the request
+   * @return JAX-RS response to check for success or failure based on status code.
+   */
+  @POST
+  @Path("repos/{org}/{repo}/statuses/{prHeadSha}")
+  public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
+      @PathParam("repo") String repo, @PathParam("prHeadSha") String prHeadSha,
+      GithubCommitStatusRequest commitStatusUpdate);
 
-    /**
-     * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given
-     * application.
-     * 
-     * @param params the general params for requests, including pagination
-     * @param bearer JWT bearer token for the target application
-     * @return list of installations for the application
-     */
-    @GET
-    @Path("app/installations")
-    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(@BeanParam BaseAPIParameters params,
-            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
+  /**
+   * Adds a comment to a pull request.
+   *
+   * @param bearer authorization header value, access token for application with access to repo
+   * @param apiVersion the version of the GH API to target when making the request
+   * @param organization the organization that owns the targeted repo
+   * @param repo the repo name that is being targeted
+   * @param pullNumber the number of the pull request to which the comment will be added.
+   * @param comment the content of the comment to add.
+   * @return response indicating the result of the operation.
+   */
+  @POST
+  @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
+  public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
+      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, String comment);
 
-    /**
-     * Retrieves an access token for a specific installation, given the applications JWT bearer and the api version.
-     * 
-     * @param bearer the authorization header value, should contain the apps signed JWT token
-     * @param apiVersion the API version to target with the request
-     * @param installationId the installation to generate an access token for
-     * @return the Github access token for the GH app installation
-     */
-    @POST
-    @Path("app/installations/{installationId}/access_tokens")
-    public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("installationId") String installationId);
+  /**
+   * Requires a JWT bearer token for the application to retrieve installations for. Returns a list
+   * of installations for the given application.
+   * 
+   * @param params the general params for requests, including pagination
+   * @param bearer JWT bearer token for the target application
+   * @return list of installations for the application
+   */
+  @GET
+  @Path("app/installations")
+  public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
+      @BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
-    /**
-     * Returns a list of repositories for the given installation.
-     * 
-     * @param params the general params for requests, including pagination
-     * @param bearer JWT bearer token for the target installation
-     * @return list of repositories for the installation as a response for pagination
-     */
-    @GET
-    @Path("installation/repositories")
-    public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
+  /**
+   * Retrieves an access token for a specific installation, given the applications JWT bearer and
+   * the api version.
+   * 
+   * @param bearer the authorization header value, should contain the apps signed JWT token
+   * @param apiVersion the API version to target with the request
+   * @param installationId the installation to generate an access token for
+   * @return the Github access token for the GH app installation
+   */
+  @POST
+  @Path("app/installations/{installationId}/access_tokens")
+  public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+      @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+      @PathParam("installationId") String installationId);
+
+  /**
+   * Returns a list of repositories for the given installation.
+   * 
+   * @param params the general params for requests, including pagination
+   * @param bearer JWT bearer token for the target installation
+   * @return list of repositories for the installation as a response for pagination
+   */
+  @GET
+  @Path("installation/repositories")
+  public Response getInstallationRepositories(@BeanParam BaseAPIParameters params,
+      @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 803567ed..3a744772 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -1,9 +1,8 @@
 /**
  * Copyright (c) 2023 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/
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
  *
@@ -19,8 +18,9 @@ import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.function.Supplier;
-
+import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
 import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.eclipse.microprofile.rest.client.inject.RestClient;
@@ -66,472 +66,525 @@ import jakarta.ws.rs.core.MultivaluedMap;
 import jakarta.ws.rs.core.Response;
 
 /**
- * This class is used to adapt Github requests to the standard validation workflow in a way that could be reused by both resource calls and
- * scheduled tasks for revalidation.
+ * This class is used to adapt Github requests to the standard validation workflow in a way that
+ * could be reused by both resource calls and scheduled tasks for revalidation.
  */
 @ApplicationScoped
 public class GithubHelper {
-    private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class);
-
-    private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}";
-
-    @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
-    String apiVersion;
-
-    @Inject
-    WebhooksConfig webhooksConfig;
-
-    @Inject
-    JwtHelper jwtHelper;
-    @Inject
-    APIMiddleware middleware;
-    @Inject
-    PersistenceDao dao;
-    @Inject
-    FilterService filters;
-    @Inject
-    JwtHelper jwt;
-
-    @Inject
-    ValidationService validation;
-    @Inject
-    ValidationStatusService validationStatus;
-    @Inject
-    GithubApplicationService ghAppService;
-
-    @RestClient
-    GithubAPI ghApi;
-
-    /**
-     * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and
-     * validate the data.
-     * 
-     * @param wrapper the wrapper for the current request
-     * @param org the name of the GH organization
-     * @param repoName the slug of the repo that has the PR to be validated
-     * @param prNo the PR number for the current request.
-     * @param forceRevalidation true if revalidation should be forced when there is no changes, false otherwise.
-     * @return the validated response if it is a valid request, or throws a web exception if there is a problem validating the request.
-     */
-    public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, String repoName, Integer prNo,
-            boolean forceRevalidation) {
-        // use logging helper to sanitize newlines as they aren't needed here
-        String fullRepoName = getFullRepoName(org, repoName);
-        // get the installation ID for the given repo if it exists, and if the PR noted exists
-        String installationId = ghAppService.getInstallationForRepo(org, repoName);
-        Optional<PullRequest> prResponse = ghAppService.getPullRequest(installationId, org, repoName, prNo);
-        if (StringUtils.isBlank(installationId)) {
-            throw new BadRequestException("Could not find an ECA app installation for repo name: " + fullRepoName);
-        } else if (prResponse.isEmpty()) {
-            throw new NotFoundException(String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
-        }
-
-        // prepare the request for consumption
-        String repoUrl = getRepoUrl(org, repoName);
-        ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl);
-
-        // build the commit sha list based on the prepared request
-        List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList();
-        // there should always be commits for a PR, but in case, lets check
-        if (commitShas.isEmpty()) {
-            throw new BadRequestException(String.format("Could not find any commits for %s#%d", fullRepoName, prNo));
-        }
-        LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
-
-        // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing.
-        GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName,
-                prResponse.get());
-        if (updatedTracking == null) {
-            throw new ServerErrorException("Error while attempting to revalidate request, try again later.",
-                    Response.Status.INTERNAL_SERVER_ERROR);
-        }
-
-        // get the commit status of commits to use for checking historic validation
-        List<CommitValidationStatus> statuses = validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas);
-        if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) {
-            throw new BadRequestException("Cannot find validation history for current non-open PR, cannot provide validation status");
-        }
-
-        // we only want to update/revalidate for open PRs, so don't do this check if the PR is merged/closed
-        if (forceRevalidation) {
-            LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo);
-            return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
-        } else if ("open".equalsIgnoreCase(prResponse.get().getState()) && commitShas.size() != statuses.size()) {
-            LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo);
-            // using the updated tracking, perform the validation
-            return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
-        }
-        return null;
+  private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class);
+
+  private static final String VALIDATION_LOGGING_MESSAGE =
+      "Setting validation state for {}/#{} to {}";
+
+  @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
+  String apiVersion;
+
+  @Inject
+  WebhooksConfig webhooksConfig;
+
+  @Inject
+  JwtHelper jwtHelper;
+  @Inject
+  APIMiddleware middleware;
+  @Inject
+  PersistenceDao dao;
+  @Inject
+  FilterService filters;
+  @Inject
+  JwtHelper jwt;
+
+  @Inject
+  ValidationService validation;
+  @Inject
+  ValidationStatusService validationStatus;
+  @Inject
+  GithubApplicationService ghAppService;
+
+  @RestClient
+  GithubAPI ghApi;
+
+  /**
+   * Using the wrapper and the passed unique information about a Github validation instance, lookup
+   * or create the tracking request and validate the data.
+   * 
+   * @param wrapper the wrapper for the current request
+   * @param org the name of the GH organization
+   * @param repoName the slug of the repo that has the PR to be validated
+   * @param prNo the PR number for the current request.
+   * @param forceRevalidation true if revalidation should be forced when there is no changes, false
+   *        otherwise.
+   * @return the validated response if it is a valid request, or throws a web exception if there is
+   *         a problem validating the request.
+   */
+  public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org,
+      String repoName, Integer prNo, boolean forceRevalidation) {
+    // use logging helper to sanitize newlines as they aren't needed here
+    String fullRepoName = getFullRepoName(org, repoName);
+    // get the installation ID for the given repo if it exists, and if the PR noted exists
+    String installationId = ghAppService.getInstallationForRepo(org, repoName);
+    Optional<PullRequest> prResponse =
+        ghAppService.getPullRequest(installationId, org, repoName, prNo);
+    if (StringUtils.isBlank(installationId)) {
+      throw new BadRequestException(
+          "Could not find an ECA app installation for repo name: " + fullRepoName);
+    } else if (prResponse.isEmpty()) {
+      throw new NotFoundException(
+          String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
     }
 
-    /**
-     * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the
-     * installation ID for access/authorization.
-     * 
-     * @param installationId the ECA app installation ID for the organization
-     * @param repositoryFullName the full name of the repository where the PR resides
-     * @param pullRequestNumber the pull request number that is being validated
-     * @param repositoryUrl the URL of the repository that contains the commits to validate
-     * @return the populated validation request for the Github request information
-     */
-    @SuppressWarnings("null")
-    public ValidationRequest generateRequest(String installationId, String orgName, String repositoryName, int pullRequestNumber,
-            String repositoryUrl) {
-        checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
-        // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful
-        if (LOGGER.isTraceEnabled()) {
-            LOGGER
-                    .trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, TransformationHelper.formatLog(orgName),
-                            TransformationHelper.formatLog(repositoryName));
-        }
-        List<GithubCommit> commits = middleware
-                .getAll(i -> ghApi
-                        .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, orgName, repositoryName, pullRequestNumber));
-        if (LOGGER.isTraceEnabled()) {
-            LOGGER
-                    .trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber,
-                            TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName));
-        }
-
-        // set up the validation request from current data
-        return ValidationRequest
-                .builder()
-                .setProvider(ProviderType.GITHUB)
-                .setRepoUrl(URI.create(repositoryUrl))
-                .setStrictMode(true)
-                .setCommits(commits
-                        .stream()
-                        .map(c -> Commit
-                                .builder()
-                                .setHash(c.getSha())
-                                .setAuthor(GitUser
-                                        .builder()
-                                        .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail()))
-                                        .setName(getNullableString(() -> c.getCommit().getAuthor().getName()))
-                                        .setExternalId(getNullableString(() -> c.getAuthor().getLogin()))
-                                        .build())
-                                .setCommitter(GitUser
-                                        .builder()
-                                        .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail()))
-                                        .setName(getNullableString(() -> c.getCommit().getCommitter().getName()))
-                                        .setExternalId(getNullableString(() -> c.getCommitter().getLogin()))
-                                        .build())
-                                .setParents(c.getParents().stream().map(ParentCommit::getSha).toList())
-                                .build())
-                        .toList())
-                .build();
-    }
+    // prepare the request for consumption
+    String repoUrl = getRepoUrl(org, repoName);
+    ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl);
 
-    /**
-     * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including
-     * the existing ECA check, we don't need to check the commits here.
-     * 
-     * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit
-     * status of the resources
-     */
-    public void sendMergeQueueStatus(GithubWebhookRequest request) {
-        if (request.getMergeGroup() == null) {
-            throw new BadRequestException("Merge group object required in webhook request to send status for merge queue");
-        }
-        // send the success status for the head SHA
-        ghApi
-                .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
-                        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
-                        request.getMergeGroup().getHeadSha(),
-                        GithubCommitStatusRequest
-                                .builder()
-                                .setDescription("Commits in merge group should be previously validated, auto-passing HEAD commit")
-                                .setState(GithubCommitStatuses.SUCCESS.toString())
-                                .setContext(webhooksConfig.github().context())
-                                .build());
+    // build the commit sha list based on the prepared request
+    List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList();
+    // there should always be commits for a PR, but in case, lets check
+    if (commitShas.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Could not find any commits for %s#%d", fullRepoName, prNo));
     }
-
-    /**
-     * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more
-     * info on the states of the validation for more information
-     * 
-     * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit
-     * status of the resources
-     * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic.
-     * @return true if the validation passed, false otherwise.
-     */
-    public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr, RequestWrapper wrapper) {
-        // null check the pull request to make sure that someone didn't push a bad value
-        PullRequest pr = request.getPullRequest();
-        if (pr == null) {
-            throw new IllegalStateException("Pull request should not be null when handling validation");
-        }
-
-        // update the status before processing
-        LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.PENDING);
-        updateCommitStatus(request, GithubCommitStatuses.PENDING);
-
-        // validate the response
-        LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(), pr.getNumber());
-        ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
-        if (r.getPassed()) {
-            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.SUCCESS);
-            updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
-        } else {
-            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.FAILURE);
-            updateCommitStatus(request, GithubCommitStatuses.FAILURE);
-        }
-        return r;
+    LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
+
+    // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing.
+    GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper,
+        installationId, org, repoName, prResponse.get());
+    if (updatedTracking == null) {
+      throw new ServerErrorException(
+          "Error while attempting to revalidate request, try again later.",
+          Response.Status.INTERNAL_SERVER_ERROR);
     }
 
-    /**
-     * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing
-     * requests as well.
-     * 
-     * @param installationId the installation ID for the ECA app in the given repository
-     * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
-     * @param pr the pull request targeted by the validation request.
-     * @return a new or updated tracking object, or null if there was an error in saving the information
-     */
-    public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, String installationId, String orgName,
-            String repositoryName, PullRequest pr) {
-        return updateGithubTrackingIfMissing(wrapper,
-                getExistingRequestInformation(wrapper, installationId, orgName, repositoryName, pr.getNumber()), pr, installationId,
-                orgName, repositoryName);
+    // get the commit status of commits to use for checking historic validation
+    List<CommitValidationStatus> statuses =
+        validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas);
+    if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) {
+      throw new BadRequestException(
+          "Cannot find validation history for current non-open PR, cannot provide validation status");
     }
 
-    /**
-     * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it.
-     * 
-     * @param tracking the optional tracking entry for the current request
-     * @param request the pull request that is being validated
-     * @param installationId the ECA app installation ID for the current request
-     * @param fullRepoName the full repo name for the validation request
-     */
-    public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, Optional<GithubWebhookTracking> tracking,
-            PullRequest request, String installationId, String orgName, String repositoryName) {
-        String fullRepoName = getFullRepoName(orgName, repositoryName);
-        // if there is no tracking present, create the missing tracking and persist it
-        GithubWebhookTracking updatedTracking;
-        if (tracking.isEmpty()) {
-            updatedTracking = new GithubWebhookTracking();
-            updatedTracking.setInstallationId(installationId);
-            updatedTracking.setLastUpdated(DateTimeHelper.now());
-            updatedTracking.setPullRequestNumber(request.getNumber());
-            updatedTracking.setRepositoryFullName(fullRepoName);
-            updatedTracking.setNeedsRevalidation(false);
-            updatedTracking.setManualRevalidationCount(0);
-            if (!"open".equalsIgnoreCase(request.getState())) {
-                LOGGER
-                        .warn("The PR {} in {} is not in an open state, and will not be validated to follow our validation practice",
-                                updatedTracking.getPullRequestNumber(), fullRepoName);
-            }
-        } else {
-            // uses the DB version as a base if available
-            updatedTracking = tracking.get();
-        }
-        // always update the head SHA and the last known state
-        updatedTracking.setLastKnownState(request.getState());
-        updatedTracking.setHeadSha(request.getHead().getSha());
-
-        // save the data, and log on its success or failure
-        List<GithubWebhookTracking> savedTracking = dao
-                .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(updatedTracking));
-        if (savedTracking.isEmpty()) {
-            LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber());
-            return null;
-        }
-        // return the updated tracking when successful
-        LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber());
-        return savedTracking.get(0);
+    // we only want to update/revalidate for open PRs, so don't do this check if the PR is
+    // merged/closed
+    if (forceRevalidation) {
+      LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo);
+      return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking),
+          vr, wrapper);
+    } else if ("open".equalsIgnoreCase(prResponse.get().getState())
+        && commitShas.size() != statuses.size()) {
+      LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits",
+          fullRepoName, prNo);
+      // using the updated tracking, perform the validation
+      return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking),
+          vr, wrapper);
     }
-
-    /**
-     * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number.
-     * 
-     * @param installationId the installation ID for the ECA app in the given repository
-     * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
-     * @param pullRequestNumber the pull request number that is being processed currently
-     * @return the webhook tracking record if it can be found, or an empty optional.
-     */
-    public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, String installationId, String orgName,
-            String repositoryName, int pullRequestNumber) {
-        checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
-        String repositoryFullName = getFullRepoName(orgName, repositoryName);
-        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-        params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId);
-        params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName);
-        params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber));
-        return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst();
+    return null;
+  }
+
+  /**
+   * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the
+   * noted pull request using the installation ID for access/authorization.
+   * 
+   * @param installationId the ECA app installation ID for the organization
+   * @param repositoryFullName the full name of the repository where the PR resides
+   * @param pullRequestNumber the pull request number that is being validated
+   * @param repositoryUrl the URL of the repository that contains the commits to validate
+   * @return the populated validation request for the Github request information
+   */
+  @SuppressWarnings("null")
+  public ValidationRequest generateRequest(String installationId, String orgName,
+      String repositoryName, int pullRequestNumber, String repositoryUrl) {
+    checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
+    // get the commits that will be validated, don't cache as changes can come in too fast for it to
+    // be useful
+    if (LOGGER.isTraceEnabled()) {
+      LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber,
+          TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName));
     }
-
-    /**
-     * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing
-     * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before
-     * the starting time.
-     * 
-     * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not
-     * lead to a loss of data/integrity.
-     */
-    public void updateAppInstallationRecords() {
-        // get the installations for the currently configured app
-        List<GithubApplicationInstallationData> installations = middleware
-                .getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt()));
-        // check that there are installations, none may indicate an issue, and better to fail pessimistically
-        if (installations.isEmpty()) {
-            LOGGER.warn("Did not find any installations for the currently configured Github application");
-            return;
-        }
-        // trace log the installations for more context
-        LOGGER.debug("Found {} installations to cache", installations.size());
-
-        // create a common timestamp that looks for entries stale for more than a day
-        Date startingTimestamp = new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli());
-        // from installations, build records and start the processing for each entry
-        List<GithubApplicationInstallation> installationRecords = installations.stream().map(this::processInstallation).toList();
-
-        // once records are prepared, persist them back to the database with updates where necessary as a batch
-        RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
-        List<GithubApplicationInstallation> repoRecords = dao
-                .add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), installationRecords);
-        if (repoRecords.size() != installationRecords.size()) {
-            LOGGER.warn("Background update to installation records had a size mismatch, cleaning will be skipped for this run");
-            return;
-        }
-
-        // build query to do cleanup of stale records
-        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-        params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId()));
-        params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, DateTimeHelper.toRFC3339(startingTimestamp));
-
-        // run the delete call, removing stale entries
-        dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
+    List<GithubCommit> commits =
+        middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId),
+            apiVersion, orgName, repositoryName, pullRequestNumber));
+    if (LOGGER.isTraceEnabled()) {
+      LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber,
+          TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName));
     }
 
-    /**
-     * Simple helper method so we don't have to repeat static strings in multiple places.
-     * 
-     * @param fullRepoName the full repo name including org for the target PR.
-     * @return the full repo URL on Github for the request
-     */
-    public static String getRepoUrl(String org, String repoName) {
-        return "https://github.com/" + org + '/' + repoName;
+    // set up the validation request from current data
+    return ValidationRequest.builder().setProvider(ProviderType.GITHUB)
+        .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true)
+        .setCommits(commits.stream()
+            .map(c -> Commit.builder().setHash(c.getSha())
+                .setAuthor(GitUser.builder()
+                    .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail()))
+                    .setName(getNullableString(() -> c.getCommit().getAuthor().getName()))
+                    .setExternalId(getNullableString(() -> c.getAuthor().getLogin())).build())
+                .setCommitter(GitUser.builder()
+                    .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail()))
+                    .setName(getNullableString(() -> c.getCommit().getCommitter().getName()))
+                    .setExternalId(getNullableString(() -> c.getCommitter().getLogin())).build())
+                .setParents(c.getParents().stream().map(ParentCommit::getSha).toList()).build())
+            .toList())
+        .build();
+  }
+
+  /**
+   * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As
+   * commits need to pass branch rules including the existing ECA check, we don't need to check the
+   * commits here.
+   * 
+   * @param request information about the request from the GH webhook on what resource requested
+   *        revalidation. Used to target the commit status of the resources
+   */
+  public void sendMergeQueueStatus(GithubWebhookRequest request) {
+    if (request.getMergeGroup() == null) {
+      throw new BadRequestException(
+          "Merge group object required in webhook request to send status for merge queue");
     }
-
-    /**
-     * Sends off a POST to update the commit status given a context for the current PR.
-     * 
-     * @param request the current webhook update request
-     * @param state the state to set the status to
-     * @param fingerprint the internal unique string for the set of commits being processed
-     */
-    private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) {
-        PullRequest pr = request.getPullRequest();
-        if (pr == null) {
-            // should not be reachable, but for safety
-            throw new IllegalStateException("Pull request should not be null when handling validation");
-        }
-
-        LOGGER
-                .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
-                        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
-        ghApi
-                .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
-                        request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(),
-                        GithubCommitStatusRequest
-                                .builder()
-                                .setDescription(state.getMessage())
-                                .setState(state.toString())
-                                .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
-                                        + request.getRepository().getFullName() + '/' + pr.getNumber())
-                                .setContext(webhooksConfig.github().context())
-                                .build());
+    // send the success status for the head SHA
+    ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
+        request.getMergeGroup().getHeadSha(),
+        GithubCommitStatusRequest.builder()
+            .setDescription(
+                "Commits in merge group should be previously validated, auto-passing HEAD commit")
+            .setState(GithubCommitStatuses.SUCCESS.toString())
+            .setContext(webhooksConfig.github().context()).build());
+  }
+
+  private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) {
+    if (errors.isEmpty()) {
+      return;
+    }
+    StringBuilder sb = new StringBuilder();
+    sb.append("The following errors were found in the validation of this pull request:\n");
+    for (String error : errors) {
+      sb.append("- ").append(error).append('\n');
+    }
+    sb.append("Please check the ECA validation status for more information.");
+
+    ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
+        request.getPullRequest().getNumber(), sb.toString());
+  }
+
+  /**
+   * Process the current request and update the checks state to pending then success or failure.
+   * Contains verbose TRACE logging for more info on the states of the validation for more
+   * information
+   * 
+   * @param request information about the request from the GH webhook on what resource requested
+   *        revalidation. Used to target the commit status of the resources
+   * @param vr the pseudo request generated from the contextual webhook data. Used to make use of
+   *        existing validation logic.
+   * @return true if the validation passed, false otherwise.
+   */
+  public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request,
+      ValidationRequest vr, RequestWrapper wrapper) {
+    // null check the pull request to make sure that someone didn't push a bad value
+    PullRequest pr = request.getPullRequest();
+    if (pr == null) {
+      throw new IllegalStateException("Pull request should not be null when handling validation");
     }
 
-    /**
-     * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved.
-     * 
-     * @param supplier the method with potentially nullable values
-     * @return the value if it can be found, or null
-     */
-    private String getNullableString(Supplier<String> supplier) {
-        try {
-            return supplier.get();
-        } catch (NullPointerException e) {
-            // suppress, as we don't care at this point if its null
-        }
-        return null;
+    // update the status before processing
+    LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(),
+        GithubCommitStatuses.PENDING);
+    updateCommitStatus(request, GithubCommitStatuses.PENDING);
+
+    // validate the response
+    LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(),
+        pr.getNumber());
+    ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
+    if (r.getPassed()) {
+      LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
+          pr.getNumber(), GithubCommitStatuses.SUCCESS);
+      updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
+    } else {
+      LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
+          pr.getNumber(), GithubCommitStatuses.FAILURE);
+      updateCommitStatus(request, GithubCommitStatuses.FAILURE);
+      commentOnFailure(request,
+          r.getCommits().values().stream().flatMap(c -> c.getErrors().stream())
+              .map(e -> e.getMessage()).collect(Collectors.toSet()));
+    }
+    return r;
+  }
+
+  /**
+   * Shortcut method that will retrieve existing GH tracking info, create new entries if missing,
+   * and will update the state of existing requests as well.
+   * 
+   * @param installationId the installation ID for the ECA app in the given repository
+   * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
+   * @param pr the pull request targeted by the validation request.
+   * @return a new or updated tracking object, or null if there was an error in saving the
+   *         information
+   */
+  public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper,
+      String installationId, String orgName, String repositoryName, PullRequest pr) {
+    return updateGithubTrackingIfMissing(wrapper, getExistingRequestInformation(wrapper,
+        installationId, orgName, repositoryName, pr.getNumber()), pr, installationId, orgName,
+        repositoryName);
+  }
+
+  /**
+   * Checks if the Github tracking is present for the current request, and if missing will generate
+   * a new record and save it.
+   * 
+   * @param tracking the optional tracking entry for the current request
+   * @param request the pull request that is being validated
+   * @param installationId the ECA app installation ID for the current request
+   * @param fullRepoName the full repo name for the validation request
+   */
+  public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper,
+      Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId,
+      String orgName, String repositoryName) {
+    String fullRepoName = getFullRepoName(orgName, repositoryName);
+    // if there is no tracking present, create the missing tracking and persist it
+    GithubWebhookTracking updatedTracking;
+    if (tracking.isEmpty()) {
+      updatedTracking = new GithubWebhookTracking();
+      updatedTracking.setInstallationId(installationId);
+      updatedTracking.setLastUpdated(DateTimeHelper.now());
+      updatedTracking.setPullRequestNumber(request.getNumber());
+      updatedTracking.setRepositoryFullName(fullRepoName);
+      updatedTracking.setNeedsRevalidation(false);
+      updatedTracking.setManualRevalidationCount(0);
+      if (!"open".equalsIgnoreCase(request.getState())) {
+        LOGGER.warn(
+            "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice",
+            updatedTracking.getPullRequestNumber(), fullRepoName);
+      }
+    } else {
+      // uses the DB version as a base if available
+      updatedTracking = tracking.get();
+    }
+    // always update the head SHA and the last known state
+    updatedTracking.setLastKnownState(request.getState());
+    updatedTracking.setHeadSha(request.getHead().getSha());
+
+    // save the data, and log on its success or failure
+    List<GithubWebhookTracking> savedTracking =
+        dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)),
+            Arrays.asList(updatedTracking));
+    if (savedTracking.isEmpty()) {
+      LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}",
+          fullRepoName, request.getNumber());
+      return null;
+    }
+    // return the updated tracking when successful
+    LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName,
+        request.getNumber());
+    return savedTracking.get(0);
+  }
+
+  /**
+   * Attempts to retrieve a webhook tracking record given the installation, repository, and pull
+   * request number.
+   * 
+   * @param installationId the installation ID for the ECA app in the given repository
+   * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
+   * @param pullRequestNumber the pull request number that is being processed currently
+   * @return the webhook tracking record if it can be found, or an empty optional.
+   */
+  public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper,
+      String installationId, String orgName, String repositoryName, int pullRequestNumber) {
+    checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
+    String repositoryFullName = getFullRepoName(orgName, repositoryName);
+    MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+    params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId);
+    params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName);
+    params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber));
+    return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params))
+        .stream().findFirst();
+  }
+
+  /**
+   * Using the configured GitHub application JWT and application ID, fetches all active
+   * installations and updates the DB cache containing the installation data. After all records are
+   * updated, the starting timestamp is used to delete stale records that were updated before the
+   * starting time.
+   * 
+   * This does not use locking, but with the way that updates are done to look for long stale
+   * entries, multiple concurrent runs would not lead to a loss of data/integrity.
+   */
+  public void updateAppInstallationRecords() {
+    // get the installations for the currently configured app
+    List<GithubApplicationInstallationData> installations =
+        middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt()));
+    // check that there are installations, none may indicate an issue, and better to fail
+    // pessimistically
+    if (installations.isEmpty()) {
+      LOGGER.warn("Did not find any installations for the currently configured Github application");
+      return;
+    }
+    // trace log the installations for more context
+    LOGGER.debug("Found {} installations to cache", installations.size());
+
+    // create a common timestamp that looks for entries stale for more than a day
+    Date startingTimestamp =
+        new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli());
+    // from installations, build records and start the processing for each entry
+    List<GithubApplicationInstallation> installationRecords =
+        installations.stream().map(this::processInstallation).toList();
+
+    // once records are prepared, persist them back to the database with updates where necessary as
+    // a batch
+    RequestWrapper wrap = new FlatRequestWrapper(
+        URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
+    List<GithubApplicationInstallation> repoRecords =
+        dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)),
+            installationRecords);
+    if (repoRecords.size() != installationRecords.size()) {
+      LOGGER.warn(
+          "Background update to installation records had a size mismatch, cleaning will be skipped for this run");
+      return;
     }
 
-    /**
-     * Validates required fields for processing requests.
-     * 
-     * @param installationId the installation ID for the ECA app in the given repository
-     * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
-     * @param pullRequestNumber the pull request number that is being processed currently
-     * @throws BadRequestException if at least one of the parameters is in an invalid state.
-     */
-    private void checkRequestParameters(String installationId, String org, String repoName, int pullRequestNumber) {
-        List<String> missingFields = new ArrayList<>();
-        if (StringUtils.isBlank(installationId)) {
-            missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
-        }
-        if (StringUtils.isBlank(org)) {
-            missingFields.add(GitEcaParameterNames.ORG_NAME_RAW);
-        }
-        if (StringUtils.isBlank(repoName)) {
-            missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW);
-        }
-        if (pullRequestNumber < 1) {
-            missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW);
-        }
-
-        // throw exception if some fields are missing as we can't continue to process the request
-        if (!missingFields.isEmpty()) {
-            throw new BadRequestException("Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' '));
-        }
+    // build query to do cleanup of stale records
+    MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+    params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
+        Integer.toString(webhooksConfig.github().appId()));
+    params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW,
+        DateTimeHelper.toRFC3339(startingTimestamp));
+
+    // run the delete call, removing stale entries
+    dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
+  }
+
+  /**
+   * Simple helper method so we don't have to repeat static strings in multiple places.
+   * 
+   * @param fullRepoName the full repo name including org for the target PR.
+   * @return the full repo URL on Github for the request
+   */
+  public static String getRepoUrl(String org, String repoName) {
+    return "https://github.com/" + org + '/' + repoName;
+  }
+
+  /**
+   * Sends off a POST to update the commit status given a context for the current PR.
+   * 
+   * @param request the current webhook update request
+   * @param state the state to set the status to
+   * @param fingerprint the internal unique string for the set of commits being processed
+   */
+  private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) {
+    PullRequest pr = request.getPullRequest();
+    if (pr == null) {
+      // should not be reachable, but for safety
+      throw new IllegalStateException("Pull request should not be null when handling validation");
     }
 
-    /**
-     * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching.
-     * Checks database for existing record, and returns record with a touched date for existing entries.
-     * 
-     * @param ghInstallation raw Github installation record for current application
-     * @return the new or updated installation record to be persisted to the database.
-     */
-    private GithubApplicationInstallation processInstallation(GithubApplicationInstallationData ghInstallation) {
-        RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
-        // build the lookup query for the current installation record
-        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-        params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId()));
-        params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId()));
-
-        // lookup existing records in the database
-        List<GithubApplicationInstallation> existingRecords = dao
-                .get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
-
-        // check for existing entry, creating if missing
-        GithubApplicationInstallation installation;
-        if (existingRecords == null || existingRecords.isEmpty()) {
-            installation = new GithubApplicationInstallation();
-            installation.setAppId(webhooksConfig.github().appId());
-            installation.setInstallationId(ghInstallation.getId());
-        } else {
-            installation = existingRecords.get(0);
-        }
-        // update the basic stats to handle renames, and update last updated time
-        // login is technically nullable, so this might be missing. This is best we can do, as we can't look up by id
-        installation
-                .setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) ? ghInstallation.getAccount().getLogin()
-                        : "UNKNOWN");
-        installation.setLastUpdated(new Date());
-        return installation;
+    LOGGER.trace("Generated access token for installation {}: {}",
+        request.getInstallation().getId(),
+        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+    ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
+        pr.getHead().getSha(),
+        GithubCommitStatusRequest.builder().setDescription(state.getMessage())
+            .setState(state.toString())
+            .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
+                + request.getRepository().getFullName() + '/' + pr.getNumber())
+            .setContext(webhooksConfig.github().context()).build());
+  }
+
+  /**
+   * Wraps a nullable value fetch to handle errors and will return null if the value can't be
+   * retrieved.
+   * 
+   * @param supplier the method with potentially nullable values
+   * @return the value if it can be found, or null
+   */
+  private String getNullableString(Supplier<String> supplier) {
+    try {
+      return supplier.get();
+    } catch (NullPointerException e) {
+      // suppress, as we don't care at this point if its null
+    }
+    return null;
+  }
+
+  /**
+   * Validates required fields for processing requests.
+   * 
+   * @param installationId the installation ID for the ECA app in the given repository
+   * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
+   * @param pullRequestNumber the pull request number that is being processed currently
+   * @throws BadRequestException if at least one of the parameters is in an invalid state.
+   */
+  private void checkRequestParameters(String installationId, String org, String repoName,
+      int pullRequestNumber) {
+    List<String> missingFields = new ArrayList<>();
+    if (StringUtils.isBlank(installationId)) {
+      missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
+    }
+    if (StringUtils.isBlank(org)) {
+      missingFields.add(GitEcaParameterNames.ORG_NAME_RAW);
+    }
+    if (StringUtils.isBlank(repoName)) {
+      missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW);
+    }
+    if (pullRequestNumber < 1) {
+      missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW);
     }
 
-    /**
-     * Retrieves the full repo name for a given org and repo name, used for storage and legacy support.
-     *
-     * @param org organization name being targeted
-     * @param repo name of repo being targeted
-     * @return the full repo name, following format of org/repo
-     */
-    public static String getFullRepoName(String org, String repo) {
-        return TransformationHelper.formatLog(org + '/' + repo);
+    // throw exception if some fields are missing as we can't continue to process the request
+    if (!missingFields.isEmpty()) {
+      throw new BadRequestException(
+          "Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' '));
+    }
+  }
+
+  /**
+   * Converts the raw installation data from Github into a short record to be persisted to database
+   * as a form of persistent caching. Checks database for existing record, and returns record with a
+   * touched date for existing entries.
+   * 
+   * @param ghInstallation raw Github installation record for current application
+   * @return the new or updated installation record to be persisted to the database.
+   */
+  private GithubApplicationInstallation processInstallation(
+      GithubApplicationInstallationData ghInstallation) {
+    RequestWrapper wrap = new FlatRequestWrapper(
+        URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
+    // build the lookup query for the current installation record
+    MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+    params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
+        Integer.toString(webhooksConfig.github().appId()));
+    params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId()));
+
+    // lookup existing records in the database
+    List<GithubApplicationInstallation> existingRecords =
+        dao.get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
+
+    // check for existing entry, creating if missing
+    GithubApplicationInstallation installation;
+    if (existingRecords == null || existingRecords.isEmpty()) {
+      installation = new GithubApplicationInstallation();
+      installation.setAppId(webhooksConfig.github().appId());
+      installation.setInstallationId(ghInstallation.getId());
+    } else {
+      installation = existingRecords.get(0);
     }
+    // update the basic stats to handle renames, and update last updated time
+    // login is technically nullable, so this might be missing. This is best we can do, as we can't
+    // look up by id
+    installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin())
+        ? ghInstallation.getAccount().getLogin()
+        : "UNKNOWN");
+    installation.setLastUpdated(new Date());
+    return installation;
+  }
+
+  /**
+   * Retrieves the full repo name for a given org and repo name, used for storage and legacy
+   * support.
+   *
+   * @param org organization name being targeted
+   * @param repo name of repo being targeted
+   * @return the full repo name, following format of org/repo
+   */
+  public static String getFullRepoName(String org, String repo) {
+    return TransformationHelper.formatLog(org + '/' + repo);
+  }
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
index 95801ddf..de91b46e 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
@@ -26,7 +26,6 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonNaming;
 import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.google.auto.value.AutoValue;
-import com.google.auto.value.extension.memoized.Memoized;
 
 /**
  * Represents an internal response for a call to this API.
@@ -35,7 +34,7 @@ import com.google.auto.value.extension.memoized.Memoized;
  */
 @AutoValue
 @JsonNaming(LowerCamelCaseStrategy.class)
-@JsonDeserialize(builder = $AutoValue_ValidationResponse.Builder.class)
+@JsonDeserialize(builder = AutoValue_ValidationResponse.Builder.class)
 public abstract class ValidationResponse {
     public static final String NIL_HASH_PLACEHOLDER = "_nil";
 
@@ -54,7 +53,6 @@ public abstract class ValidationResponse {
         return getErrorCount() <= 0;
     }
 
-    @Memoized
     public int getErrorCount() {
         return getCommits().values().stream().mapToInt(s -> s.getErrors().size()).sum();
     }
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
new file mode 100644
index 00000000..10c3000a
--- /dev/null
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -0,0 +1,133 @@
+package org.eclipsefoundation.git.eca.helper;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.net.URI;
+import java.time.ZonedDateTime;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.eclipsefoundation.git.eca.api.GithubAPI;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
+import org.eclipsefoundation.git.eca.model.Commit;
+import org.eclipsefoundation.git.eca.model.CommitStatus;
+import org.eclipsefoundation.git.eca.model.GitUser;
+import org.eclipsefoundation.git.eca.model.ValidationRequest;
+import org.eclipsefoundation.git.eca.model.ValidationResponse;
+import org.eclipsefoundation.git.eca.namespace.APIStatusCode;
+import org.eclipsefoundation.git.eca.namespace.ProviderType;
+import org.eclipsefoundation.git.eca.service.ValidationService;
+import org.eclipsefoundation.http.model.FlatRequestWrapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import io.quarkus.test.InjectMock;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.mockito.InjectSpy;
+import jakarta.inject.Inject;
+
+@QuarkusTest
+class GithubHelperTest {
+
+  @Inject
+  GithubHelper helper;
+
+  @RestClient
+  @InjectSpy
+  GithubAPI ghApi;
+
+  @InjectMock
+  ValidationService validationService;
+
+  @Test
+  void testHandleGithubWebhookValidation_WithErrors() {
+    String commitHash = "abc123";
+    String orgName = "test-org";
+    String repoName = "test-repo";
+    int prNumber = 42;
+
+    GithubWebhookRequest request =
+        createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash);
+    Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com");
+
+    ValidationRequest vr = ValidationRequest.builder()
+        .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
+        .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit)).build();
+
+    ValidationResponse mockResponse =
+        createErrorValidationResponse(commitHash, "Missing ECA for user");
+
+    when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
+        .thenReturn(mockResponse);
+
+    helper.handleGithubWebhookValidation(request, vr, null);
+
+    String expectedComment =
+        "The following errors were found in the validation of this pull request:\n"
+            + "- Missing ECA for user\n"
+            + "Please check the ECA validation status for more information.";
+
+    verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName),
+        Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedComment));
+  }
+
+  /**
+   * Creates a GitHub webhook request for testing purposes.
+   * 
+   * @param installationId The installation ID
+   * @param repoName The repository name
+   * @param orgName The organization name
+   * @param prNumber The pull request number
+   * @param commitSha The commit SHA
+   * @return GithubWebhookRequest instance
+   */
+  private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName,
+      String orgName, int prNumber, String commitSha) {
+    return GithubWebhookRequest.builder()
+        .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build())
+        .setRepository(GithubWebhookRequest.Repository.builder().setName(repoName)
+            .setFullName(orgName + "/" + repoName)
+            .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build())
+            .setHtmlUrl("https://github.com/" + orgName + "/" + repoName).build())
+        .setPullRequest(
+            GithubWebhookRequest.PullRequest.builder().setNumber(prNumber).setState("open")
+                .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build())
+                .build())
+        .build();
+  }
+
+  /**
+   * Creates a test commit with specified user details.
+   * 
+   * @param commitHash The commit hash
+   * @param userName The user's name
+   * @param userEmail The user's email
+   * @return Commit instance
+   */
+  private Commit createTestCommit(String commitHash, String userName, String userEmail) {
+    GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build();
+    return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser)
+        .setSubject("Test commit").setBody("Test commit body").setParents(Collections.emptyList())
+        .build();
+  }
+
+  /**
+   * Creates a validation response with an error status.
+   * 
+   * @param commitHash The commit hash to associate the error with
+   * @param errorMessage The error message
+   * @return ValidationResponse instance
+   */
+  private ValidationResponse createErrorValidationResponse(String commitHash, String errorMessage) {
+    Map<String, CommitStatus> commits = new HashMap<>();
+    CommitStatus status = CommitStatus.builder().build();
+    status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR);
+    commits.put(commitHash, status);
+
+    return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits)
+        .setTrackedProject(true).setStrictMode(true).build();
+  }
+
+}
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index 7031f2d0..0da330ce 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -1,14 +1,13 @@
 /*********************************************************************
-* Copyright (c) 2023 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: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org>
-*
-* SPDX-License-Identifier: EPL-2.0
-**********************************************************************/
+ * Copyright (c) 2023 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: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ **********************************************************************/
 package org.eclipsefoundation.git.eca.test.api;
 
 import java.time.LocalDateTime;
@@ -41,73 +40,75 @@ import io.quarkus.test.Mock;
 @ApplicationScoped
 public class MockGithubAPI implements GithubAPI {
 
-    Map<String, Map<Integer, List<GithubCommit>>> commits;
-    Map<String, Map<String, String>> commitStatuses;
+  Map<String, Map<Integer, List<GithubCommit>>> commits;
+  Map<String, Map<String, String>> commitStatuses;
 
-    public MockGithubAPI() {
-        this.commitStatuses = new HashMap<>();
-        this.commits = new HashMap<>();
-        this.commits
-                .put("eclipsefdn/sample",
-                        Map
-                                .of(42, Arrays
-                                        .asList(GithubCommit
-                                                .builder()
-                                                .setSha("sha-1234")
-                                                .setAuthor(GithubCommitUser.builder().setLogin("testuser").build())
-                                                .setCommitter(GithubCommitUser.builder().setLogin("testuser").build())
-                                                .setParents(Collections.emptyList())
-                                                .setCommit(CommitData
-                                                        .builder()
-                                                        .setAuthor(GitCommitUser
-                                                                .builder()
-                                                                .setName("The Wizard")
-                                                                .setEmail("code.wiz@important.co")
-                                                                .build())
-                                                        .setCommitter(GitCommitUser
-                                                                .builder()
-                                                                .setName("The Wizard")
-                                                                .setEmail("code.wiz@important.co")
-                                                                .build())
-                                                        .build())
-                                                .build())));
-    }
+  public MockGithubAPI() {
+    this.commitStatuses = new HashMap<>();
+    this.commits = new HashMap<>();
+    this.commits.put("eclipsefdn/sample",
+        Map.of(42,
+            Arrays.asList(GithubCommit.builder().setSha("sha-1234")
+                .setAuthor(GithubCommitUser.builder().setLogin("testuser").build())
+                .setCommitter(GithubCommitUser.builder().setLogin("testuser").build())
+                .setParents(Collections.emptyList())
+                .setCommit(CommitData.builder()
+                    .setAuthor(GitCommitUser.builder().setName("The Wizard")
+                        .setEmail("code.wiz@important.co").build())
+                    .setCommitter(GitCommitUser.builder().setName("The Wizard")
+                        .setEmail("code.wiz@important.co").build())
+                    .build())
+                .build())));
+  }
 
-    @Override
-    public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, int pullNumber) {
-        return PullRequest.builder().build();
-    }
+  @Override
+  public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo,
+      int pullNumber) {
+    return PullRequest.builder().build();
+  }
 
-    @Override
-    public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, String repo, int pullNumber) {
-        String repoFullName = org + '/' + repo;
-        List<GithubCommit> results = commits.get(repoFullName).get(pullNumber);
-        if (results == null || !results.isEmpty()) {
-            return RestResponse.status(404);
-        }
-        return RestResponse.ok(results);
+  @Override
+  public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org,
+      String repo, int pullNumber) {
+    String repoFullName = org + '/' + repo;
+    List<GithubCommit> results = commits.get(repoFullName).get(pullNumber);
+    if (results == null || !results.isEmpty()) {
+      return RestResponse.status(404);
     }
+    return RestResponse.ok(results);
+  }
 
-    @Override
-    public Response updateStatus(String bearer, String apiVersion, String org, String repo, String prHeadSha,
-            GithubCommitStatusRequest commitStatusUpdate) {
-        String repoFullName = org + '/' + repo;
-        commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, commitStatusUpdate.getState(), (k, v) -> v);
-        return Response.ok().build();
-    }
+  @Override
+  public Response updateStatus(String bearer, String apiVersion, String org, String repo,
+      String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) {
+    String repoFullName = org + '/' + repo;
+    commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha,
+        commitStatusUpdate.getState(), (k, v) -> v);
+    return Response.ok().build();
+  }
 
-    @Override
-    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(BaseAPIParameters params, String bearer) {
-        throw new UnsupportedOperationException("Unimplemented method 'getInstallations'");
-    }
+  @Override
+  public Response addComment(String bearer, String apiVersion, String organization, String repo,
+      int pullNumber, String comment) {
+    return Response.ok().build();
+  }
 
-    @Override
-    public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, String installationId) {
-        return GithubAccessToken.builder().setToken("gh-token-" + installationId).setExpiresAt(LocalDateTime.now().plusHours(1L)).build();
-    }
+  @Override
+  public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
+      BaseAPIParameters params, String bearer) {
+    throw new UnsupportedOperationException("Unimplemented method 'getInstallations'");
+  }
+
+  @Override
+  public GithubAccessToken getNewAccessToken(String bearer, String apiVersion,
+      String installationId) {
+    return GithubAccessToken.builder().setToken("gh-token-" + installationId)
+        .setExpiresAt(LocalDateTime.now().plusHours(1L)).build();
+  }
+
+  @Override
+  public Response getInstallationRepositories(BaseAPIParameters params, String bearer) {
+    throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'");
+  }
 
-    @Override
-    public Response getInstallationRepositories(BaseAPIParameters params, String bearer) {
-        throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'");
-    }
 }
-- 
GitLab


From 86e8b328921c7c5aa5ff0effbf33ae5d887bbdb8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Tue, 22 Apr 2025 17:02:51 +0200
Subject: [PATCH 02/14] feat: using proper body structure against github API

---
 .../org/eclipsefoundation/git/eca/api/GithubAPI.java  |  8 +++++---
 .../git/eca/helper/GithubHelper.java                  |  7 ++++++-
 .../git/eca/helper/GithubHelperTest.java              | 11 ++++++-----
 .../git/eca/test/api/MockGithubAPI.java               |  3 ++-
 4 files changed, 19 insertions(+), 10 deletions(-)

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index bced49eb..d6bcc0f4 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -19,6 +19,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
 import org.jboss.resteasy.reactive.RestResponse;
 
 import jakarta.ws.rs.BeanParam;
@@ -92,21 +93,22 @@ public interface GithubAPI {
       GithubCommitStatusRequest commitStatusUpdate);
 
   /**
-   * Adds a comment to a pull request.
+   * Adds a review comment to a pull request.
    *
    * @param bearer authorization header value, access token for application with access to repo
    * @param apiVersion the version of the GH API to target when making the request
    * @param organization the organization that owns the targeted repo
    * @param repo the repo name that is being targeted
    * @param pullNumber the number of the pull request to which the comment will be added.
-   * @param comment the content of the comment to add.
+   * @param commentRequest the review comment request containing the comment details
    * @return response indicating the result of the operation.
    */
   @POST
   @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
   public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
       @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
-      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, String comment);
+      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, 
+      GithubPullRequestCommentRequest commentRequest);
 
   /**
    * Requires a JWT bearer token for the application to retrieve installations for. Returns a list
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 3a744772..aafdd909 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -30,6 +30,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.config.WebhooksConfig;
@@ -262,7 +263,11 @@ public class GithubHelper {
 
     ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
         request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
-        request.getPullRequest().getNumber(), sb.toString());
+        request.getPullRequest().getNumber(), GithubPullRequestCommentRequest.builder()
+        .setBody(sb.toString())
+        .setCommitId(request.getPullRequest().getHead().getSha())
+        .setPath(".")
+        .build());
   }
 
   /**
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 10c3000a..4ea43028 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -11,6 +11,7 @@ import java.util.Map;
 
 import org.eclipse.microprofile.rest.client.inject.RestClient;
 import org.eclipsefoundation.git.eca.api.GithubAPI;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.model.Commit;
 import org.eclipsefoundation.git.eca.model.CommitStatus;
@@ -20,7 +21,6 @@ import org.eclipsefoundation.git.eca.model.ValidationResponse;
 import org.eclipsefoundation.git.eca.namespace.APIStatusCode;
 import org.eclipsefoundation.git.eca.namespace.ProviderType;
 import org.eclipsefoundation.git.eca.service.ValidationService;
-import org.eclipsefoundation.http.model.FlatRequestWrapper;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 import io.quarkus.test.InjectMock;
@@ -64,13 +64,14 @@ class GithubHelperTest {
 
     helper.handleGithubWebhookValidation(request, vr, null);
 
-    String expectedComment =
-        "The following errors were found in the validation of this pull request:\n"
+    var expectedRequestBody = GithubPullRequestCommentRequest.builder()
+        .setBody("The following errors were found in the validation of this pull request:\n"
             + "- Missing ECA for user\n"
-            + "Please check the ECA validation status for more information.";
+            + "Please check the ECA validation status for more information.")
+        .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build();
 
     verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName),
-        Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedComment));
+        Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody));
   }
 
   /**
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index 0da330ce..82eb09a2 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -30,6 +30,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.jboss.resteasy.reactive.RestResponse;
 
@@ -89,7 +90,7 @@ public class MockGithubAPI implements GithubAPI {
 
   @Override
   public Response addComment(String bearer, String apiVersion, String organization, String repo,
-      int pullNumber, String comment) {
+      int pullNumber, GithubPullRequestCommentRequest comment) {
     return Response.ok().build();
   }
 
-- 
GitLab


From 1b371e6b58bd0e382827696a9f81da764575b0ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Wed, 23 Apr 2025 08:54:10 +0200
Subject: [PATCH 03/14] chore: fix formatting

---
 .../git/eca/api/GithubAPI.java                |  207 ++--
 .../GithubPullRequestCommentRequest.java      |   64 ++
 .../git/eca/helper/GithubHelper.java          | 1011 +++++++++--------
 .../git/eca/model/ValidationResponse.java     |   35 +-
 .../git/eca/helper/GithubHelperTest.java      |  204 ++--
 .../git/eca/test/api/MockGithubAPI.java       |  122 +-
 6 files changed, 868 insertions(+), 775 deletions(-)
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index d6bcc0f4..74e36207 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -42,112 +42,117 @@ import jakarta.ws.rs.core.Response;
 @Produces("application/json")
 public interface GithubAPI {
 
-  /**
-   * Retrieves information about a certain pull request in a repo if it exists
-   * 
-   * @param bearer authorization header value, access token for application with access to repo
-   * @param apiVersion the version of the GH API to target when making the request
-   * @param repoFull the full repo name that is being targeted
-   * @param pullNumber the pull request number
-   * @return information about the given pull request if it exists.
-   */
-  @GET
-  @Path("repos/{org}/{repo}/pulls/{pullNumber}")
-  public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
-      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber);
+    /**
+     * Retrieves information about a certain pull request in a repo if it exists
+     * 
+     * @param bearer authorization header value, access token for application with access to repo
+     * @param apiVersion the version of the GH API to target when making the request
+     * @param repoFull the full repo name that is being targeted
+     * @param pullNumber the pull request number
+     * @return information about the given pull request if it exists.
+     */
+    @GET
+    @Path("repos/{org}/{repo}/pulls/{pullNumber}")
+    public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+            @PathParam("org") String organization, @PathParam("repo") String repo,
+            @PathParam("pullNumber") int pullNumber);
 
-  /**
-   * Retrieves a list of commits related to the given pull request.
-   * 
-   * @param bearer authorization header value, access token for application with access to repo
-   * @param apiVersion the version of the GH API to target when making the request
-   * @param repo the repo name that is being targeted
-   * @param pullNumber the pull request number
-   * @return list of commits associated with the pull request, wrapped in a jax-rs response
-   */
-  @GET
-  @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits")
-  public RestResponse<List<GithubCommit>> getCommits(
-      @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
-      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber);
+    /**
+     * Retrieves a list of commits related to the given pull request.
+     * 
+     * @param bearer authorization header value, access token for application with access to repo
+     * @param apiVersion the version of the GH API to target when making the request
+     * @param repo the repo name that is being targeted
+     * @param pullNumber the pull request number
+     * @return list of commits associated with the pull request, wrapped in a jax-rs response
+     */
+    @GET
+    @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits")
+    public RestResponse<List<GithubCommit>> getCommits(
+            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+            @PathParam("org") String organization, @PathParam("repo") String repo,
+            @PathParam("pullNumber") int pullNumber);
 
-  /**
-   * Posts an update to the Github API, using an access token to update a given pull requests commit
-   * status, targeted using the head sha.
-   * 
-   * @param bearer authorization header value, access token for application with access to repo
-   * @param apiVersion the version of the GH API to target when making the request
-   * @param organization the organization that owns the targeted repo
-   * @param repo the repo name that is being targeted
-   * @param prHeadSha the head SHA for the request
-   * @param commitStatusUpdate the status body sent with the request
-   * @return JAX-RS response to check for success or failure based on status code.
-   */
-  @POST
-  @Path("repos/{org}/{repo}/statuses/{prHeadSha}")
-  public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
-      @PathParam("repo") String repo, @PathParam("prHeadSha") String prHeadSha,
-      GithubCommitStatusRequest commitStatusUpdate);
+    /**
+     * Posts an update to the Github API, using an access token to update a given pull requests
+     * commit status, targeted using the head sha.
+     * 
+     * @param bearer authorization header value, access token for application with access to repo
+     * @param apiVersion the version of the GH API to target when making the request
+     * @param organization the organization that owns the targeted repo
+     * @param repo the repo name that is being targeted
+     * @param prHeadSha the head SHA for the request
+     * @param commitStatusUpdate the status body sent with the request
+     * @return JAX-RS response to check for success or failure based on status code.
+     */
+    @POST
+    @Path("repos/{org}/{repo}/statuses/{prHeadSha}")
+    public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+            @PathParam("org") String organization, @PathParam("repo") String repo,
+            @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate);
 
-  /**
-   * Adds a review comment to a pull request.
-   *
-   * @param bearer authorization header value, access token for application with access to repo
-   * @param apiVersion the version of the GH API to target when making the request
-   * @param organization the organization that owns the targeted repo
-   * @param repo the repo name that is being targeted
-   * @param pullNumber the number of the pull request to which the comment will be added.
-   * @param commentRequest the review comment request containing the comment details
-   * @return response indicating the result of the operation.
-   */
-  @POST
-  @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
-  public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-      @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization,
-      @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, 
-      GithubPullRequestCommentRequest commentRequest);
+    /**
+     * Adds a review comment to a pull request.
+     *
+     * @param bearer authorization header value, access token for application with access to repo
+     * @param apiVersion the version of the GH API to target when making the request
+     * @param organization the organization that owns the targeted repo
+     * @param repo the repo name that is being targeted
+     * @param pullNumber the number of the pull request to which the comment will be added.
+     * @param commentRequest the review comment request containing the comment details
+     * @return response indicating the result of the operation.
+     */
+    @POST
+    @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
+    public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+            @PathParam("org") String organization, @PathParam("repo") String repo,
+            @PathParam("pullNumber") int pullNumber,
+            GithubPullRequestCommentRequest commentRequest);
 
-  /**
-   * Requires a JWT bearer token for the application to retrieve installations for. Returns a list
-   * of installations for the given application.
-   * 
-   * @param params the general params for requests, including pagination
-   * @param bearer JWT bearer token for the target application
-   * @return list of installations for the application
-   */
-  @GET
-  @Path("app/installations")
-  public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
-      @BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
+    /**
+     * Requires a JWT bearer token for the application to retrieve installations for. Returns a list
+     * of installations for the given application.
+     * 
+     * @param params the general params for requests, including pagination
+     * @param bearer JWT bearer token for the target application
+     * @return list of installations for the application
+     */
+    @GET
+    @Path("app/installations")
+    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
+            @BeanParam BaseAPIParameters params,
+            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
-  /**
-   * Retrieves an access token for a specific installation, given the applications JWT bearer and
-   * the api version.
-   * 
-   * @param bearer the authorization header value, should contain the apps signed JWT token
-   * @param apiVersion the API version to target with the request
-   * @param installationId the installation to generate an access token for
-   * @return the Github access token for the GH app installation
-   */
-  @POST
-  @Path("app/installations/{installationId}/access_tokens")
-  public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-      @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-      @PathParam("installationId") String installationId);
+    /**
+     * Retrieves an access token for a specific installation, given the applications JWT bearer and
+     * the api version.
+     * 
+     * @param bearer the authorization header value, should contain the apps signed JWT token
+     * @param apiVersion the API version to target with the request
+     * @param installationId the installation to generate an access token for
+     * @return the Github access token for the GH app installation
+     */
+    @POST
+    @Path("app/installations/{installationId}/access_tokens")
+    public GithubAccessToken getNewAccessToken(
+            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+            @PathParam("installationId") String installationId);
 
-  /**
-   * Returns a list of repositories for the given installation.
-   * 
-   * @param params the general params for requests, including pagination
-   * @param bearer JWT bearer token for the target installation
-   * @return list of repositories for the installation as a response for pagination
-   */
-  @GET
-  @Path("installation/repositories")
-  public Response getInstallationRepositories(@BeanParam BaseAPIParameters params,
-      @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
+    /**
+     * Returns a list of repositories for the given installation.
+     * 
+     * @param params the general params for requests, including pagination
+     * @param bearer JWT bearer token for the target installation
+     * @return list of repositories for the installation as a response for pagination
+     */
+    @GET
+    @Path("installation/repositories")
+    public Response getInstallationRepositories(@BeanParam BaseAPIParameters params,
+            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
new file mode 100644
index 00000000..50b4a9d3
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
@@ -0,0 +1,64 @@
+/**
+ * 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.api.models;
+
+import jakarta.annotation.Nullable;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+/**
+ * Model for creating a review comment on a pull request.
+ */
+@AutoValue
+@JsonDeserialize(builder = AutoValue_GithubPullRequestCommentRequest.Builder.class)
+public abstract class GithubPullRequestCommentRequest {
+    public abstract String getBody();
+
+    public abstract String getCommitId();
+
+    public abstract String getPath();
+
+    @Nullable
+    public abstract Integer getLine();
+
+    @Nullable
+    public abstract String getSide();
+
+    @Nullable
+    public abstract Integer getStartLine();
+
+    @Nullable
+    public abstract String getStartSide();
+
+    public static Builder builder() {
+        return new AutoValue_GithubPullRequestCommentRequest.Builder();
+    }
+
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+        public abstract Builder setBody(String body);
+
+        public abstract Builder setCommitId(String commitId);
+
+        public abstract Builder setPath(String path);
+
+        public abstract Builder setLine(@Nullable Integer line);
+
+        public abstract Builder setSide(@Nullable String side);
+
+        public abstract Builder setStartLine(@Nullable Integer startLine);
+
+        public abstract Builder setStartSide(@Nullable String startSide);
+
+        public abstract GithubPullRequestCommentRequest build();
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index aafdd909..8f37d74f 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -72,524 +72,543 @@ import jakarta.ws.rs.core.Response;
  */
 @ApplicationScoped
 public class GithubHelper {
-  private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class);
-
-  private static final String VALIDATION_LOGGING_MESSAGE =
-      "Setting validation state for {}/#{} to {}";
-
-  @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
-  String apiVersion;
-
-  @Inject
-  WebhooksConfig webhooksConfig;
-
-  @Inject
-  JwtHelper jwtHelper;
-  @Inject
-  APIMiddleware middleware;
-  @Inject
-  PersistenceDao dao;
-  @Inject
-  FilterService filters;
-  @Inject
-  JwtHelper jwt;
-
-  @Inject
-  ValidationService validation;
-  @Inject
-  ValidationStatusService validationStatus;
-  @Inject
-  GithubApplicationService ghAppService;
-
-  @RestClient
-  GithubAPI ghApi;
-
-  /**
-   * Using the wrapper and the passed unique information about a Github validation instance, lookup
-   * or create the tracking request and validate the data.
-   * 
-   * @param wrapper the wrapper for the current request
-   * @param org the name of the GH organization
-   * @param repoName the slug of the repo that has the PR to be validated
-   * @param prNo the PR number for the current request.
-   * @param forceRevalidation true if revalidation should be forced when there is no changes, false
-   *        otherwise.
-   * @return the validated response if it is a valid request, or throws a web exception if there is
-   *         a problem validating the request.
-   */
-  public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org,
-      String repoName, Integer prNo, boolean forceRevalidation) {
-    // use logging helper to sanitize newlines as they aren't needed here
-    String fullRepoName = getFullRepoName(org, repoName);
-    // get the installation ID for the given repo if it exists, and if the PR noted exists
-    String installationId = ghAppService.getInstallationForRepo(org, repoName);
-    Optional<PullRequest> prResponse =
-        ghAppService.getPullRequest(installationId, org, repoName, prNo);
-    if (StringUtils.isBlank(installationId)) {
-      throw new BadRequestException(
-          "Could not find an ECA app installation for repo name: " + fullRepoName);
-    } else if (prResponse.isEmpty()) {
-      throw new NotFoundException(
-          String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
+    private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class);
+
+    private static final String VALIDATION_LOGGING_MESSAGE =
+            "Setting validation state for {}/#{} to {}";
+
+    @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
+    String apiVersion;
+
+    @Inject
+    WebhooksConfig webhooksConfig;
+
+    @Inject
+    JwtHelper jwtHelper;
+    @Inject
+    APIMiddleware middleware;
+    @Inject
+    PersistenceDao dao;
+    @Inject
+    FilterService filters;
+    @Inject
+    JwtHelper jwt;
+
+    @Inject
+    ValidationService validation;
+    @Inject
+    ValidationStatusService validationStatus;
+    @Inject
+    GithubApplicationService ghAppService;
+
+    @RestClient
+    GithubAPI ghApi;
+
+    /**
+     * Using the wrapper and the passed unique information about a Github validation instance,
+     * lookup or create the tracking request and validate the data.
+     * 
+     * @param wrapper the wrapper for the current request
+     * @param org the name of the GH organization
+     * @param repoName the slug of the repo that has the PR to be validated
+     * @param prNo the PR number for the current request.
+     * @param forceRevalidation true if revalidation should be forced when there is no changes,
+     *        false otherwise.
+     * @return the validated response if it is a valid request, or throws a web exception if there
+     *         is a problem validating the request.
+     */
+    public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org,
+            String repoName, Integer prNo, boolean forceRevalidation) {
+        // use logging helper to sanitize newlines as they aren't needed here
+        String fullRepoName = getFullRepoName(org, repoName);
+        // get the installation ID for the given repo if it exists, and if the PR noted exists
+        String installationId = ghAppService.getInstallationForRepo(org, repoName);
+        Optional<PullRequest> prResponse =
+                ghAppService.getPullRequest(installationId, org, repoName, prNo);
+        if (StringUtils.isBlank(installationId)) {
+            throw new BadRequestException(
+                    "Could not find an ECA app installation for repo name: " + fullRepoName);
+        } else if (prResponse.isEmpty()) {
+            throw new NotFoundException(
+                    String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
+        }
+
+        // prepare the request for consumption
+        String repoUrl = getRepoUrl(org, repoName);
+        ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl);
+
+        // build the commit sha list based on the prepared request
+        List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList();
+        // there should always be commits for a PR, but in case, lets check
+        if (commitShas.isEmpty()) {
+            throw new BadRequestException(
+                    String.format("Could not find any commits for %s#%d", fullRepoName, prNo));
+        }
+        LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
+
+        // retrieve the webhook tracking info, or generate an entry to track this PR if it's
+        // missing.
+        GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper,
+                installationId, org, repoName, prResponse.get());
+        if (updatedTracking == null) {
+            throw new ServerErrorException(
+                    "Error while attempting to revalidate request, try again later.",
+                    Response.Status.INTERNAL_SERVER_ERROR);
+        }
+
+        // get the commit status of commits to use for checking historic validation
+        List<CommitValidationStatus> statuses =
+                validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas);
+        if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) {
+            throw new BadRequestException(
+                    "Cannot find validation history for current non-open PR, cannot provide validation status");
+        }
+
+        // we only want to update/revalidate for open PRs, so don't do this check if the PR is
+        // merged/closed
+        if (forceRevalidation) {
+            LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo);
+            return handleGithubWebhookValidation(
+                    GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
+        } else if ("open".equalsIgnoreCase(prResponse.get().getState())
+                && commitShas.size() != statuses.size()) {
+            LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits",
+                    fullRepoName, prNo);
+            // using the updated tracking, perform the validation
+            return handleGithubWebhookValidation(
+                    GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
+        }
+        return null;
     }
 
-    // prepare the request for consumption
-    String repoUrl = getRepoUrl(org, repoName);
-    ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl);
-
-    // build the commit sha list based on the prepared request
-    List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList();
-    // there should always be commits for a PR, but in case, lets check
-    if (commitShas.isEmpty()) {
-      throw new BadRequestException(
-          String.format("Could not find any commits for %s#%d", fullRepoName, prNo));
-    }
-    LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
-
-    // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing.
-    GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper,
-        installationId, org, repoName, prResponse.get());
-    if (updatedTracking == null) {
-      throw new ServerErrorException(
-          "Error while attempting to revalidate request, try again later.",
-          Response.Status.INTERNAL_SERVER_ERROR);
+    /**
+     * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from
+     * the noted pull request using the installation ID for access/authorization.
+     * 
+     * @param installationId the ECA app installation ID for the organization
+     * @param repositoryFullName the full name of the repository where the PR resides
+     * @param pullRequestNumber the pull request number that is being validated
+     * @param repositoryUrl the URL of the repository that contains the commits to validate
+     * @return the populated validation request for the Github request information
+     */
+    @SuppressWarnings("null")
+    public ValidationRequest generateRequest(String installationId, String orgName,
+            String repositoryName, int pullRequestNumber, String repositoryUrl) {
+        checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
+        // get the commits that will be validated, don't cache as changes can come in too fast for
+        // it to
+        // be useful
+        if (LOGGER.isTraceEnabled()) {
+            LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber,
+                    TransformationHelper.formatLog(orgName),
+                    TransformationHelper.formatLog(repositoryName));
+        }
+        List<GithubCommit> commits =
+                middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId),
+                        apiVersion, orgName, repositoryName, pullRequestNumber));
+        if (LOGGER.isTraceEnabled()) {
+            LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(),
+                    pullRequestNumber, TransformationHelper.formatLog(orgName),
+                    TransformationHelper.formatLog(repositoryName));
+        }
+
+        // set up the validation request from current data
+        return ValidationRequest.builder().setProvider(ProviderType.GITHUB)
+                .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true)
+                .setCommits(commits.stream()
+                        .map(c -> Commit.builder().setHash(c.getSha()).setAuthor(GitUser.builder()
+                                .setMail(getNullableString(
+                                        () -> c.getCommit().getAuthor().getEmail()))
+                                .setName(getNullableString(
+                                        () -> c.getCommit().getAuthor().getName()))
+                                .setExternalId(getNullableString(() -> c.getAuthor().getLogin()))
+                                .build())
+                                .setCommitter(GitUser.builder()
+                                        .setMail(getNullableString(
+                                                () -> c.getCommit().getCommitter().getEmail()))
+                                        .setName(getNullableString(
+                                                () -> c.getCommit().getCommitter().getName()))
+                                        .setExternalId(getNullableString(
+                                                () -> c.getCommitter().getLogin()))
+                                        .build())
+                                .setParents(
+                                        c.getParents().stream().map(ParentCommit::getSha).toList())
+                                .build())
+                        .toList())
+                .build();
     }
 
-    // get the commit status of commits to use for checking historic validation
-    List<CommitValidationStatus> statuses =
-        validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas);
-    if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) {
-      throw new BadRequestException(
-          "Cannot find validation history for current non-open PR, cannot provide validation status");
+    /**
+     * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As
+     * commits need to pass branch rules including the existing ECA check, we don't need to check
+     * the commits here.
+     * 
+     * @param request information about the request from the GH webhook on what resource requested
+     *        revalidation. Used to target the commit status of the resources
+     */
+    public void sendMergeQueueStatus(GithubWebhookRequest request) {
+        if (request.getMergeGroup() == null) {
+            throw new BadRequestException(
+                    "Merge group object required in webhook request to send status for merge queue");
+        }
+        // send the success status for the head SHA
+        ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()),
+                apiVersion, request.getRepository().getOwner().getLogin(),
+                request.getRepository().getName(), request.getMergeGroup().getHeadSha(),
+                GithubCommitStatusRequest.builder().setDescription(
+                        "Commits in merge group should be previously validated, auto-passing HEAD commit")
+                        .setState(GithubCommitStatuses.SUCCESS.toString())
+                        .setContext(webhooksConfig.github().context()).build());
     }
 
-    // we only want to update/revalidate for open PRs, so don't do this check if the PR is
-    // merged/closed
-    if (forceRevalidation) {
-      LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo);
-      return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking),
-          vr, wrapper);
-    } else if ("open".equalsIgnoreCase(prResponse.get().getState())
-        && commitShas.size() != statuses.size()) {
-      LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits",
-          fullRepoName, prNo);
-      // using the updated tracking, perform the validation
-      return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking),
-          vr, wrapper);
-    }
-    return null;
-  }
-
-  /**
-   * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the
-   * noted pull request using the installation ID for access/authorization.
-   * 
-   * @param installationId the ECA app installation ID for the organization
-   * @param repositoryFullName the full name of the repository where the PR resides
-   * @param pullRequestNumber the pull request number that is being validated
-   * @param repositoryUrl the URL of the repository that contains the commits to validate
-   * @return the populated validation request for the Github request information
-   */
-  @SuppressWarnings("null")
-  public ValidationRequest generateRequest(String installationId, String orgName,
-      String repositoryName, int pullRequestNumber, String repositoryUrl) {
-    checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
-    // get the commits that will be validated, don't cache as changes can come in too fast for it to
-    // be useful
-    if (LOGGER.isTraceEnabled()) {
-      LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber,
-          TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName));
-    }
-    List<GithubCommit> commits =
-        middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId),
-            apiVersion, orgName, repositoryName, pullRequestNumber));
-    if (LOGGER.isTraceEnabled()) {
-      LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber,
-          TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName));
+    private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) {
+        if (errors.isEmpty()) {
+            return;
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append("The following errors were found in the validation of this pull request:\n");
+        for (String error : errors) {
+            sb.append("- ").append(error).append('\n');
+        }
+        sb.append("Please check the ECA validation status for more information.");
+
+        ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+                request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
+                request.getPullRequest().getNumber(),
+                GithubPullRequestCommentRequest.builder().setBody(sb.toString())
+                        .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".")
+                        .build());
     }
 
-    // set up the validation request from current data
-    return ValidationRequest.builder().setProvider(ProviderType.GITHUB)
-        .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true)
-        .setCommits(commits.stream()
-            .map(c -> Commit.builder().setHash(c.getSha())
-                .setAuthor(GitUser.builder()
-                    .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail()))
-                    .setName(getNullableString(() -> c.getCommit().getAuthor().getName()))
-                    .setExternalId(getNullableString(() -> c.getAuthor().getLogin())).build())
-                .setCommitter(GitUser.builder()
-                    .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail()))
-                    .setName(getNullableString(() -> c.getCommit().getCommitter().getName()))
-                    .setExternalId(getNullableString(() -> c.getCommitter().getLogin())).build())
-                .setParents(c.getParents().stream().map(ParentCommit::getSha).toList()).build())
-            .toList())
-        .build();
-  }
-
-  /**
-   * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As
-   * commits need to pass branch rules including the existing ECA check, we don't need to check the
-   * commits here.
-   * 
-   * @param request information about the request from the GH webhook on what resource requested
-   *        revalidation. Used to target the commit status of the resources
-   */
-  public void sendMergeQueueStatus(GithubWebhookRequest request) {
-    if (request.getMergeGroup() == null) {
-      throw new BadRequestException(
-          "Merge group object required in webhook request to send status for merge queue");
-    }
-    // send the success status for the head SHA
-    ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
-        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
-        request.getMergeGroup().getHeadSha(),
-        GithubCommitStatusRequest.builder()
-            .setDescription(
-                "Commits in merge group should be previously validated, auto-passing HEAD commit")
-            .setState(GithubCommitStatuses.SUCCESS.toString())
-            .setContext(webhooksConfig.github().context()).build());
-  }
-
-  private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) {
-    if (errors.isEmpty()) {
-      return;
-    }
-    StringBuilder sb = new StringBuilder();
-    sb.append("The following errors were found in the validation of this pull request:\n");
-    for (String error : errors) {
-      sb.append("- ").append(error).append('\n');
-    }
-    sb.append("Please check the ECA validation status for more information.");
-
-    ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
-        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
-        request.getPullRequest().getNumber(), GithubPullRequestCommentRequest.builder()
-        .setBody(sb.toString())
-        .setCommitId(request.getPullRequest().getHead().getSha())
-        .setPath(".")
-        .build());
-  }
-
-  /**
-   * Process the current request and update the checks state to pending then success or failure.
-   * Contains verbose TRACE logging for more info on the states of the validation for more
-   * information
-   * 
-   * @param request information about the request from the GH webhook on what resource requested
-   *        revalidation. Used to target the commit status of the resources
-   * @param vr the pseudo request generated from the contextual webhook data. Used to make use of
-   *        existing validation logic.
-   * @return true if the validation passed, false otherwise.
-   */
-  public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request,
-      ValidationRequest vr, RequestWrapper wrapper) {
-    // null check the pull request to make sure that someone didn't push a bad value
-    PullRequest pr = request.getPullRequest();
-    if (pr == null) {
-      throw new IllegalStateException("Pull request should not be null when handling validation");
+    /**
+     * Process the current request and update the checks state to pending then success or failure.
+     * Contains verbose TRACE logging for more info on the states of the validation for more
+     * information
+     * 
+     * @param request information about the request from the GH webhook on what resource requested
+     *        revalidation. Used to target the commit status of the resources
+     * @param vr the pseudo request generated from the contextual webhook data. Used to make use of
+     *        existing validation logic.
+     * @return true if the validation passed, false otherwise.
+     */
+    public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request,
+            ValidationRequest vr, RequestWrapper wrapper) {
+        // null check the pull request to make sure that someone didn't push a bad value
+        PullRequest pr = request.getPullRequest();
+        if (pr == null) {
+            throw new IllegalStateException(
+                    "Pull request should not be null when handling validation");
+        }
+
+        // update the status before processing
+        LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
+                pr.getNumber(), GithubCommitStatuses.PENDING);
+        updateCommitStatus(request, GithubCommitStatuses.PENDING);
+
+        // validate the response
+        LOGGER.trace("Begining validation of request for {}/#{}",
+                request.getRepository().getFullName(), pr.getNumber());
+        ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
+        if (r.getPassed()) {
+            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
+                    pr.getNumber(), GithubCommitStatuses.SUCCESS);
+            updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
+        } else {
+            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
+                    pr.getNumber(), GithubCommitStatuses.FAILURE);
+            updateCommitStatus(request, GithubCommitStatuses.FAILURE);
+            commentOnFailure(request,
+                    r.getCommits().values().stream().flatMap(c -> c.getErrors().stream())
+                            .map(e -> e.getMessage()).collect(Collectors.toSet()));
+        }
+        return r;
     }
 
-    // update the status before processing
-    LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(),
-        GithubCommitStatuses.PENDING);
-    updateCommitStatus(request, GithubCommitStatuses.PENDING);
-
-    // validate the response
-    LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(),
-        pr.getNumber());
-    ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
-    if (r.getPassed()) {
-      LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
-          pr.getNumber(), GithubCommitStatuses.SUCCESS);
-      updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
-    } else {
-      LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
-          pr.getNumber(), GithubCommitStatuses.FAILURE);
-      updateCommitStatus(request, GithubCommitStatuses.FAILURE);
-      commentOnFailure(request,
-          r.getCommits().values().stream().flatMap(c -> c.getErrors().stream())
-              .map(e -> e.getMessage()).collect(Collectors.toSet()));
-    }
-    return r;
-  }
-
-  /**
-   * Shortcut method that will retrieve existing GH tracking info, create new entries if missing,
-   * and will update the state of existing requests as well.
-   * 
-   * @param installationId the installation ID for the ECA app in the given repository
-   * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
-   * @param pr the pull request targeted by the validation request.
-   * @return a new or updated tracking object, or null if there was an error in saving the
-   *         information
-   */
-  public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper,
-      String installationId, String orgName, String repositoryName, PullRequest pr) {
-    return updateGithubTrackingIfMissing(wrapper, getExistingRequestInformation(wrapper,
-        installationId, orgName, repositoryName, pr.getNumber()), pr, installationId, orgName,
-        repositoryName);
-  }
-
-  /**
-   * Checks if the Github tracking is present for the current request, and if missing will generate
-   * a new record and save it.
-   * 
-   * @param tracking the optional tracking entry for the current request
-   * @param request the pull request that is being validated
-   * @param installationId the ECA app installation ID for the current request
-   * @param fullRepoName the full repo name for the validation request
-   */
-  public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper,
-      Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId,
-      String orgName, String repositoryName) {
-    String fullRepoName = getFullRepoName(orgName, repositoryName);
-    // if there is no tracking present, create the missing tracking and persist it
-    GithubWebhookTracking updatedTracking;
-    if (tracking.isEmpty()) {
-      updatedTracking = new GithubWebhookTracking();
-      updatedTracking.setInstallationId(installationId);
-      updatedTracking.setLastUpdated(DateTimeHelper.now());
-      updatedTracking.setPullRequestNumber(request.getNumber());
-      updatedTracking.setRepositoryFullName(fullRepoName);
-      updatedTracking.setNeedsRevalidation(false);
-      updatedTracking.setManualRevalidationCount(0);
-      if (!"open".equalsIgnoreCase(request.getState())) {
-        LOGGER.warn(
-            "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice",
-            updatedTracking.getPullRequestNumber(), fullRepoName);
-      }
-    } else {
-      // uses the DB version as a base if available
-      updatedTracking = tracking.get();
+    /**
+     * Shortcut method that will retrieve existing GH tracking info, create new entries if missing,
+     * and will update the state of existing requests as well.
+     * 
+     * @param installationId the installation ID for the ECA app in the given repository
+     * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
+     * @param pr the pull request targeted by the validation request.
+     * @return a new or updated tracking object, or null if there was an error in saving the
+     *         information
+     */
+    public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper,
+            String installationId, String orgName, String repositoryName, PullRequest pr) {
+        return updateGithubTrackingIfMissing(
+                wrapper, getExistingRequestInformation(wrapper, installationId, orgName,
+                        repositoryName, pr.getNumber()),
+                pr, installationId, orgName, repositoryName);
     }
-    // always update the head SHA and the last known state
-    updatedTracking.setLastKnownState(request.getState());
-    updatedTracking.setHeadSha(request.getHead().getSha());
-
-    // save the data, and log on its success or failure
-    List<GithubWebhookTracking> savedTracking =
-        dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)),
-            Arrays.asList(updatedTracking));
-    if (savedTracking.isEmpty()) {
-      LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}",
-          fullRepoName, request.getNumber());
-      return null;
-    }
-    // return the updated tracking when successful
-    LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName,
-        request.getNumber());
-    return savedTracking.get(0);
-  }
-
-  /**
-   * Attempts to retrieve a webhook tracking record given the installation, repository, and pull
-   * request number.
-   * 
-   * @param installationId the installation ID for the ECA app in the given repository
-   * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
-   * @param pullRequestNumber the pull request number that is being processed currently
-   * @return the webhook tracking record if it can be found, or an empty optional.
-   */
-  public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper,
-      String installationId, String orgName, String repositoryName, int pullRequestNumber) {
-    checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
-    String repositoryFullName = getFullRepoName(orgName, repositoryName);
-    MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-    params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId);
-    params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName);
-    params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber));
-    return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params))
-        .stream().findFirst();
-  }
-
-  /**
-   * Using the configured GitHub application JWT and application ID, fetches all active
-   * installations and updates the DB cache containing the installation data. After all records are
-   * updated, the starting timestamp is used to delete stale records that were updated before the
-   * starting time.
-   * 
-   * This does not use locking, but with the way that updates are done to look for long stale
-   * entries, multiple concurrent runs would not lead to a loss of data/integrity.
-   */
-  public void updateAppInstallationRecords() {
-    // get the installations for the currently configured app
-    List<GithubApplicationInstallationData> installations =
-        middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt()));
-    // check that there are installations, none may indicate an issue, and better to fail
-    // pessimistically
-    if (installations.isEmpty()) {
-      LOGGER.warn("Did not find any installations for the currently configured Github application");
-      return;
-    }
-    // trace log the installations for more context
-    LOGGER.debug("Found {} installations to cache", installations.size());
-
-    // create a common timestamp that looks for entries stale for more than a day
-    Date startingTimestamp =
-        new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli());
-    // from installations, build records and start the processing for each entry
-    List<GithubApplicationInstallation> installationRecords =
-        installations.stream().map(this::processInstallation).toList();
-
-    // once records are prepared, persist them back to the database with updates where necessary as
-    // a batch
-    RequestWrapper wrap = new FlatRequestWrapper(
-        URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
-    List<GithubApplicationInstallation> repoRecords =
-        dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)),
-            installationRecords);
-    if (repoRecords.size() != installationRecords.size()) {
-      LOGGER.warn(
-          "Background update to installation records had a size mismatch, cleaning will be skipped for this run");
-      return;
+
+    /**
+     * Checks if the Github tracking is present for the current request, and if missing will
+     * generate a new record and save it.
+     * 
+     * @param tracking the optional tracking entry for the current request
+     * @param request the pull request that is being validated
+     * @param installationId the ECA app installation ID for the current request
+     * @param fullRepoName the full repo name for the validation request
+     */
+    public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper,
+            Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId,
+            String orgName, String repositoryName) {
+        String fullRepoName = getFullRepoName(orgName, repositoryName);
+        // if there is no tracking present, create the missing tracking and persist it
+        GithubWebhookTracking updatedTracking;
+        if (tracking.isEmpty()) {
+            updatedTracking = new GithubWebhookTracking();
+            updatedTracking.setInstallationId(installationId);
+            updatedTracking.setLastUpdated(DateTimeHelper.now());
+            updatedTracking.setPullRequestNumber(request.getNumber());
+            updatedTracking.setRepositoryFullName(fullRepoName);
+            updatedTracking.setNeedsRevalidation(false);
+            updatedTracking.setManualRevalidationCount(0);
+            if (!"open".equalsIgnoreCase(request.getState())) {
+                LOGGER.warn(
+                        "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice",
+                        updatedTracking.getPullRequestNumber(), fullRepoName);
+            }
+        } else {
+            // uses the DB version as a base if available
+            updatedTracking = tracking.get();
+        }
+        // always update the head SHA and the last known state
+        updatedTracking.setLastKnownState(request.getState());
+        updatedTracking.setHeadSha(request.getHead().getSha());
+
+        // save the data, and log on its success or failure
+        List<GithubWebhookTracking> savedTracking =
+                dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)),
+                        Arrays.asList(updatedTracking));
+        if (savedTracking.isEmpty()) {
+            LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}",
+                    fullRepoName, request.getNumber());
+            return null;
+        }
+        // return the updated tracking when successful
+        LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}",
+                fullRepoName, request.getNumber());
+        return savedTracking.get(0);
     }
 
-    // build query to do cleanup of stale records
-    MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-    params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
-        Integer.toString(webhooksConfig.github().appId()));
-    params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW,
-        DateTimeHelper.toRFC3339(startingTimestamp));
-
-    // run the delete call, removing stale entries
-    dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
-  }
-
-  /**
-   * Simple helper method so we don't have to repeat static strings in multiple places.
-   * 
-   * @param fullRepoName the full repo name including org for the target PR.
-   * @return the full repo URL on Github for the request
-   */
-  public static String getRepoUrl(String org, String repoName) {
-    return "https://github.com/" + org + '/' + repoName;
-  }
-
-  /**
-   * Sends off a POST to update the commit status given a context for the current PR.
-   * 
-   * @param request the current webhook update request
-   * @param state the state to set the status to
-   * @param fingerprint the internal unique string for the set of commits being processed
-   */
-  private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) {
-    PullRequest pr = request.getPullRequest();
-    if (pr == null) {
-      // should not be reachable, but for safety
-      throw new IllegalStateException("Pull request should not be null when handling validation");
+    /**
+     * Attempts to retrieve a webhook tracking record given the installation, repository, and pull
+     * request number.
+     * 
+     * @param installationId the installation ID for the ECA app in the given repository
+     * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
+     * @param pullRequestNumber the pull request number that is being processed currently
+     * @return the webhook tracking record if it can be found, or an empty optional.
+     */
+    public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper,
+            String installationId, String orgName, String repositoryName, int pullRequestNumber) {
+        checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
+        String repositoryFullName = getFullRepoName(orgName, repositoryName);
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId);
+        params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName);
+        params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW,
+                Integer.toString(pullRequestNumber));
+        return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params))
+                .stream().findFirst();
     }
 
-    LOGGER.trace("Generated access token for installation {}: {}",
-        request.getInstallation().getId(),
-        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
-    ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
-        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
-        pr.getHead().getSha(),
-        GithubCommitStatusRequest.builder().setDescription(state.getMessage())
-            .setState(state.toString())
-            .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
-                + request.getRepository().getFullName() + '/' + pr.getNumber())
-            .setContext(webhooksConfig.github().context()).build());
-  }
-
-  /**
-   * Wraps a nullable value fetch to handle errors and will return null if the value can't be
-   * retrieved.
-   * 
-   * @param supplier the method with potentially nullable values
-   * @return the value if it can be found, or null
-   */
-  private String getNullableString(Supplier<String> supplier) {
-    try {
-      return supplier.get();
-    } catch (NullPointerException e) {
-      // suppress, as we don't care at this point if its null
+    /**
+     * Using the configured GitHub application JWT and application ID, fetches all active
+     * installations and updates the DB cache containing the installation data. After all records
+     * are updated, the starting timestamp is used to delete stale records that were updated before
+     * the starting time.
+     * 
+     * This does not use locking, but with the way that updates are done to look for long stale
+     * entries, multiple concurrent runs would not lead to a loss of data/integrity.
+     */
+    public void updateAppInstallationRecords() {
+        // get the installations for the currently configured app
+        List<GithubApplicationInstallationData> installations =
+                middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt()));
+        // check that there are installations, none may indicate an issue, and better to fail
+        // pessimistically
+        if (installations.isEmpty()) {
+            LOGGER.warn(
+                    "Did not find any installations for the currently configured Github application");
+            return;
+        }
+        // trace log the installations for more context
+        LOGGER.debug("Found {} installations to cache", installations.size());
+
+        // create a common timestamp that looks for entries stale for more than a day
+        Date startingTimestamp =
+                new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli());
+        // from installations, build records and start the processing for each entry
+        List<GithubApplicationInstallation> installationRecords =
+                installations.stream().map(this::processInstallation).toList();
+
+        // once records are prepared, persist them back to the database with updates where necessary
+        // as
+        // a batch
+        RequestWrapper wrap = new FlatRequestWrapper(
+                URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
+        List<GithubApplicationInstallation> repoRecords =
+                dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)),
+                        installationRecords);
+        if (repoRecords.size() != installationRecords.size()) {
+            LOGGER.warn(
+                    "Background update to installation records had a size mismatch, cleaning will be skipped for this run");
+            return;
+        }
+
+        // build query to do cleanup of stale records
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
+                Integer.toString(webhooksConfig.github().appId()));
+        params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW,
+                DateTimeHelper.toRFC3339(startingTimestamp));
+
+        // run the delete call, removing stale entries
+        dao.delete(
+                new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
     }
-    return null;
-  }
-
-  /**
-   * Validates required fields for processing requests.
-   * 
-   * @param installationId the installation ID for the ECA app in the given repository
-   * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
-   * @param pullRequestNumber the pull request number that is being processed currently
-   * @throws BadRequestException if at least one of the parameters is in an invalid state.
-   */
-  private void checkRequestParameters(String installationId, String org, String repoName,
-      int pullRequestNumber) {
-    List<String> missingFields = new ArrayList<>();
-    if (StringUtils.isBlank(installationId)) {
-      missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
+
+    /**
+     * Simple helper method so we don't have to repeat static strings in multiple places.
+     * 
+     * @param fullRepoName the full repo name including org for the target PR.
+     * @return the full repo URL on Github for the request
+     */
+    public static String getRepoUrl(String org, String repoName) {
+        return "https://github.com/" + org + '/' + repoName;
     }
-    if (StringUtils.isBlank(org)) {
-      missingFields.add(GitEcaParameterNames.ORG_NAME_RAW);
+
+    /**
+     * Sends off a POST to update the commit status given a context for the current PR.
+     * 
+     * @param request the current webhook update request
+     * @param state the state to set the status to
+     * @param fingerprint the internal unique string for the set of commits being processed
+     */
+    private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) {
+        PullRequest pr = request.getPullRequest();
+        if (pr == null) {
+            // should not be reachable, but for safety
+            throw new IllegalStateException(
+                    "Pull request should not be null when handling validation");
+        }
+
+        LOGGER.trace("Generated access token for installation {}: {}",
+                request.getInstallation().getId(),
+                jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()),
+                apiVersion, request.getRepository().getOwner().getLogin(),
+                request.getRepository().getName(), pr.getHead().getSha(),
+                GithubCommitStatusRequest.builder().setDescription(state.getMessage())
+                        .setState(state.toString())
+                        .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
+                                + request.getRepository().getFullName() + '/' + pr.getNumber())
+                        .setContext(webhooksConfig.github().context()).build());
     }
-    if (StringUtils.isBlank(repoName)) {
-      missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW);
+
+    /**
+     * Wraps a nullable value fetch to handle errors and will return null if the value can't be
+     * retrieved.
+     * 
+     * @param supplier the method with potentially nullable values
+     * @return the value if it can be found, or null
+     */
+    private String getNullableString(Supplier<String> supplier) {
+        try {
+            return supplier.get();
+        } catch (NullPointerException e) {
+            // suppress, as we don't care at this point if its null
+        }
+        return null;
     }
-    if (pullRequestNumber < 1) {
-      missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW);
+
+    /**
+     * Validates required fields for processing requests.
+     * 
+     * @param installationId the installation ID for the ECA app in the given repository
+     * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
+     * @param pullRequestNumber the pull request number that is being processed currently
+     * @throws BadRequestException if at least one of the parameters is in an invalid state.
+     */
+    private void checkRequestParameters(String installationId, String org, String repoName,
+            int pullRequestNumber) {
+        List<String> missingFields = new ArrayList<>();
+        if (StringUtils.isBlank(installationId)) {
+            missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
+        }
+        if (StringUtils.isBlank(org)) {
+            missingFields.add(GitEcaParameterNames.ORG_NAME_RAW);
+        }
+        if (StringUtils.isBlank(repoName)) {
+            missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW);
+        }
+        if (pullRequestNumber < 1) {
+            missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW);
+        }
+
+        // throw exception if some fields are missing as we can't continue to process the request
+        if (!missingFields.isEmpty()) {
+            throw new BadRequestException("Missing fields in order to prepare request: "
+                    + StringUtils.join(missingFields, ' '));
+        }
     }
 
-    // throw exception if some fields are missing as we can't continue to process the request
-    if (!missingFields.isEmpty()) {
-      throw new BadRequestException(
-          "Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' '));
+    /**
+     * Converts the raw installation data from Github into a short record to be persisted to
+     * database as a form of persistent caching. Checks database for existing record, and returns
+     * record with a touched date for existing entries.
+     * 
+     * @param ghInstallation raw Github installation record for current application
+     * @return the new or updated installation record to be persisted to the database.
+     */
+    private GithubApplicationInstallation processInstallation(
+            GithubApplicationInstallationData ghInstallation) {
+        RequestWrapper wrap = new FlatRequestWrapper(
+                URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
+        // build the lookup query for the current installation record
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
+                Integer.toString(webhooksConfig.github().appId()));
+        params.add(GitEcaParameterNames.INSTALLATION_ID_RAW,
+                Integer.toString(ghInstallation.getId()));
+
+        // lookup existing records in the database
+        List<GithubApplicationInstallation> existingRecords = dao.get(
+                new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
+
+        // check for existing entry, creating if missing
+        GithubApplicationInstallation installation;
+        if (existingRecords == null || existingRecords.isEmpty()) {
+            installation = new GithubApplicationInstallation();
+            installation.setAppId(webhooksConfig.github().appId());
+            installation.setInstallationId(ghInstallation.getId());
+        } else {
+            installation = existingRecords.get(0);
+        }
+        // update the basic stats to handle renames, and update last updated time
+        // login is technically nullable, so this might be missing. This is best we can do, as we
+        // can't
+        // look up by id
+        installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin())
+                ? ghInstallation.getAccount().getLogin()
+                : "UNKNOWN");
+        installation.setLastUpdated(new Date());
+        return installation;
     }
-  }
-
-  /**
-   * Converts the raw installation data from Github into a short record to be persisted to database
-   * as a form of persistent caching. Checks database for existing record, and returns record with a
-   * touched date for existing entries.
-   * 
-   * @param ghInstallation raw Github installation record for current application
-   * @return the new or updated installation record to be persisted to the database.
-   */
-  private GithubApplicationInstallation processInstallation(
-      GithubApplicationInstallationData ghInstallation) {
-    RequestWrapper wrap = new FlatRequestWrapper(
-        URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
-    // build the lookup query for the current installation record
-    MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-    params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
-        Integer.toString(webhooksConfig.github().appId()));
-    params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId()));
-
-    // lookup existing records in the database
-    List<GithubApplicationInstallation> existingRecords =
-        dao.get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
-
-    // check for existing entry, creating if missing
-    GithubApplicationInstallation installation;
-    if (existingRecords == null || existingRecords.isEmpty()) {
-      installation = new GithubApplicationInstallation();
-      installation.setAppId(webhooksConfig.github().appId());
-      installation.setInstallationId(ghInstallation.getId());
-    } else {
-      installation = existingRecords.get(0);
+
+    /**
+     * Retrieves the full repo name for a given org and repo name, used for storage and legacy
+     * support.
+     *
+     * @param org organization name being targeted
+     * @param repo name of repo being targeted
+     * @return the full repo name, following format of org/repo
+     */
+    public static String getFullRepoName(String org, String repo) {
+        return TransformationHelper.formatLog(org + '/' + repo);
     }
-    // update the basic stats to handle renames, and update last updated time
-    // login is technically nullable, so this might be missing. This is best we can do, as we can't
-    // look up by id
-    installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin())
-        ? ghInstallation.getAccount().getLogin()
-        : "UNKNOWN");
-    installation.setLastUpdated(new Date());
-    return installation;
-  }
-
-  /**
-   * Retrieves the full repo name for a given org and repo name, used for storage and legacy
-   * support.
-   *
-   * @param org organization name being targeted
-   * @param repo name of repo being targeted
-   * @return the full repo name, following format of org/repo
-   */
-  public static String getFullRepoName(String org, String repo) {
-    return TransformationHelper.formatLog(org + '/' + repo);
-  }
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
index de91b46e..2a5ea7a3 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
@@ -1,14 +1,13 @@
 /*********************************************************************
-* Copyright (c) 2020 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
-*
-* SPDX-License-Identifier: EPL-2.0
-**********************************************************************/
+ * Copyright (c) 2020 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ **********************************************************************/
 package org.eclipsefoundation.git.eca.model;
 
 import java.time.ZonedDateTime;
@@ -60,9 +59,11 @@ public abstract class ValidationResponse {
     /** @param error message to add to the API response */
     public void addError(String hash, String error, APIStatusCode code) {
         if (this.getTrackedProject() || this.getStrictMode()) {
-            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addError(error, code);
+            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build())
+                    .addError(error, code);
         } else {
-            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addWarning(error, code);
+            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build())
+                    .addWarning(error, code);
         }
     }
 
@@ -73,7 +74,8 @@ public abstract class ValidationResponse {
     /**
      * Converts the APIResponse to a web response with appropriate status.
      *
-     * @return a web response with status {@link Status.OK} if the commits pass validation, {@link Status.FORBIDDEN} otherwise.
+     * @return a web response with status {@link Status.OK} if the commits pass validation,
+     *         {@link Status.FORBIDDEN} otherwise.
      */
     public Response toResponse() {
         // update error count before returning
@@ -85,11 +87,8 @@ public abstract class ValidationResponse {
     }
 
     public static Builder builder() {
-        return new AutoValue_ValidationResponse.Builder()
-                .setStrictMode(false)
-                .setTrackedProject(false)
-                .setTime(ZonedDateTime.now())
-                .setCommits(new HashMap<>());
+        return new AutoValue_ValidationResponse.Builder().setStrictMode(false)
+                .setTrackedProject(false).setTime(ZonedDateTime.now()).setCommits(new HashMap<>());
     }
 
     @AutoValue.Builder
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 4ea43028..d3785ed8 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -31,104 +31,110 @@ import jakarta.inject.Inject;
 @QuarkusTest
 class GithubHelperTest {
 
-  @Inject
-  GithubHelper helper;
-
-  @RestClient
-  @InjectSpy
-  GithubAPI ghApi;
-
-  @InjectMock
-  ValidationService validationService;
-
-  @Test
-  void testHandleGithubWebhookValidation_WithErrors() {
-    String commitHash = "abc123";
-    String orgName = "test-org";
-    String repoName = "test-repo";
-    int prNumber = 42;
-
-    GithubWebhookRequest request =
-        createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash);
-    Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com");
-
-    ValidationRequest vr = ValidationRequest.builder()
-        .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
-        .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit)).build();
-
-    ValidationResponse mockResponse =
-        createErrorValidationResponse(commitHash, "Missing ECA for user");
-
-    when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
-        .thenReturn(mockResponse);
-
-    helper.handleGithubWebhookValidation(request, vr, null);
-
-    var expectedRequestBody = GithubPullRequestCommentRequest.builder()
-        .setBody("The following errors were found in the validation of this pull request:\n"
-            + "- Missing ECA for user\n"
-            + "Please check the ECA validation status for more information.")
-        .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build();
-
-    verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName),
-        Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody));
-  }
-
-  /**
-   * Creates a GitHub webhook request for testing purposes.
-   * 
-   * @param installationId The installation ID
-   * @param repoName The repository name
-   * @param orgName The organization name
-   * @param prNumber The pull request number
-   * @param commitSha The commit SHA
-   * @return GithubWebhookRequest instance
-   */
-  private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName,
-      String orgName, int prNumber, String commitSha) {
-    return GithubWebhookRequest.builder()
-        .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build())
-        .setRepository(GithubWebhookRequest.Repository.builder().setName(repoName)
-            .setFullName(orgName + "/" + repoName)
-            .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build())
-            .setHtmlUrl("https://github.com/" + orgName + "/" + repoName).build())
-        .setPullRequest(
-            GithubWebhookRequest.PullRequest.builder().setNumber(prNumber).setState("open")
-                .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build())
-                .build())
-        .build();
-  }
-
-  /**
-   * Creates a test commit with specified user details.
-   * 
-   * @param commitHash The commit hash
-   * @param userName The user's name
-   * @param userEmail The user's email
-   * @return Commit instance
-   */
-  private Commit createTestCommit(String commitHash, String userName, String userEmail) {
-    GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build();
-    return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser)
-        .setSubject("Test commit").setBody("Test commit body").setParents(Collections.emptyList())
-        .build();
-  }
-
-  /**
-   * Creates a validation response with an error status.
-   * 
-   * @param commitHash The commit hash to associate the error with
-   * @param errorMessage The error message
-   * @return ValidationResponse instance
-   */
-  private ValidationResponse createErrorValidationResponse(String commitHash, String errorMessage) {
-    Map<String, CommitStatus> commits = new HashMap<>();
-    CommitStatus status = CommitStatus.builder().build();
-    status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR);
-    commits.put(commitHash, status);
-
-    return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits)
-        .setTrackedProject(true).setStrictMode(true).build();
-  }
+    @Inject
+    GithubHelper helper;
+
+    @RestClient
+    @InjectSpy
+    GithubAPI ghApi;
+
+    @InjectMock
+    ValidationService validationService;
+
+    @Test
+    void testHandleGithubWebhookValidation_WithErrors() {
+        String commitHash = "abc123";
+        String orgName = "test-org";
+        String repoName = "test-repo";
+        int prNumber = 42;
+
+        GithubWebhookRequest request =
+                createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash);
+        Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com");
+
+        ValidationRequest vr = ValidationRequest.builder()
+                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
+                .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit))
+                .build();
+
+        ValidationResponse mockResponse =
+                createErrorValidationResponse(commitHash, "Missing ECA for user");
+
+        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
+                .thenReturn(mockResponse);
+
+        helper.handleGithubWebhookValidation(request, vr, null);
+
+        var expectedRequestBody = GithubPullRequestCommentRequest.builder()
+                .setBody("The following errors were found in the validation of this pull request:\n"
+                        + "- Missing ECA for user\n"
+                        + "Please check the ECA validation status for more information.")
+                .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build();
+
+        verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName),
+                Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody));
+    }
+
+    /**
+     * Creates a GitHub webhook request for testing purposes.
+     * 
+     * @param installationId The installation ID
+     * @param repoName The repository name
+     * @param orgName The organization name
+     * @param prNumber The pull request number
+     * @param commitSha The commit SHA
+     * @return GithubWebhookRequest instance
+     */
+    private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName,
+            String orgName, int prNumber, String commitSha) {
+        return GithubWebhookRequest.builder()
+                .setInstallation(
+                        GithubWebhookRequest.Installation.builder().setId(installationId).build())
+                .setRepository(
+                        GithubWebhookRequest.Repository.builder().setName(repoName)
+                                .setFullName(orgName + "/" + repoName)
+                                .setOwner(GithubWebhookRequest.RepositoryOwner.builder()
+                                        .setLogin(orgName).build())
+                                .setHtmlUrl("https://github.com/" + orgName + "/" + repoName)
+                                .build())
+                .setPullRequest(GithubWebhookRequest.PullRequest.builder().setNumber(prNumber)
+                        .setState("open").setHead(GithubWebhookRequest.PullRequestHead.builder()
+                                .setSha(commitSha).build())
+                        .build())
+                .build();
+    }
+
+    /**
+     * Creates a test commit with specified user details.
+     * 
+     * @param commitHash The commit hash
+     * @param userName The user's name
+     * @param userEmail The user's email
+     * @return Commit instance
+     */
+    private Commit createTestCommit(String commitHash, String userName, String userEmail) {
+        GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build();
+        return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser)
+                .setSubject("Test commit").setBody("Test commit body")
+                .setParents(Collections.emptyList()).build();
+    }
+
+    /**
+     * Creates a validation response with an error status.
+     * 
+     * @param commitHash The commit hash to associate the error with
+     * @param errorMessage The error message
+     * @return ValidationResponse instance
+     */
+    private ValidationResponse createErrorValidationResponse(String commitHash,
+            String errorMessage) {
+        Map<String, CommitStatus> commits = new HashMap<>();
+        CommitStatus status = CommitStatus.builder().build();
+        status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR);
+        commits.put(commitHash, status);
+
+        return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits)
+                .setTrackedProject(true).setStrictMode(true).build();
+    }
 
 }
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index 82eb09a2..5f146677 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -41,75 +41,75 @@ import io.quarkus.test.Mock;
 @ApplicationScoped
 public class MockGithubAPI implements GithubAPI {
 
-  Map<String, Map<Integer, List<GithubCommit>>> commits;
-  Map<String, Map<String, String>> commitStatuses;
+    Map<String, Map<Integer, List<GithubCommit>>> commits;
+    Map<String, Map<String, String>> commitStatuses;
 
-  public MockGithubAPI() {
-    this.commitStatuses = new HashMap<>();
-    this.commits = new HashMap<>();
-    this.commits.put("eclipsefdn/sample",
-        Map.of(42,
-            Arrays.asList(GithubCommit.builder().setSha("sha-1234")
-                .setAuthor(GithubCommitUser.builder().setLogin("testuser").build())
-                .setCommitter(GithubCommitUser.builder().setLogin("testuser").build())
-                .setParents(Collections.emptyList())
-                .setCommit(CommitData.builder()
-                    .setAuthor(GitCommitUser.builder().setName("The Wizard")
-                        .setEmail("code.wiz@important.co").build())
-                    .setCommitter(GitCommitUser.builder().setName("The Wizard")
-                        .setEmail("code.wiz@important.co").build())
-                    .build())
-                .build())));
-  }
+    public MockGithubAPI() {
+        this.commitStatuses = new HashMap<>();
+        this.commits = new HashMap<>();
+        this.commits.put("eclipsefdn/sample",
+                Map.of(42, Arrays.asList(GithubCommit.builder().setSha("sha-1234")
+                        .setAuthor(GithubCommitUser.builder().setLogin("testuser").build())
+                        .setCommitter(GithubCommitUser.builder().setLogin("testuser").build())
+                        .setParents(Collections.emptyList())
+                        .setCommit(CommitData.builder()
+                                .setAuthor(GitCommitUser.builder().setName("The Wizard")
+                                        .setEmail("code.wiz@important.co").build())
+                                .setCommitter(GitCommitUser.builder().setName("The Wizard")
+                                        .setEmail("code.wiz@important.co").build())
+                                .build())
+                        .build())));
+    }
 
-  @Override
-  public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo,
-      int pullNumber) {
-    return PullRequest.builder().build();
-  }
+    @Override
+    public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo,
+            int pullNumber) {
+        return PullRequest.builder().build();
+    }
 
-  @Override
-  public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org,
-      String repo, int pullNumber) {
-    String repoFullName = org + '/' + repo;
-    List<GithubCommit> results = commits.get(repoFullName).get(pullNumber);
-    if (results == null || !results.isEmpty()) {
-      return RestResponse.status(404);
+    @Override
+    public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org,
+            String repo, int pullNumber) {
+        String repoFullName = org + '/' + repo;
+        List<GithubCommit> results = commits.get(repoFullName).get(pullNumber);
+        if (results == null || !results.isEmpty()) {
+            return RestResponse.status(404);
+        }
+        return RestResponse.ok(results);
     }
-    return RestResponse.ok(results);
-  }
 
-  @Override
-  public Response updateStatus(String bearer, String apiVersion, String org, String repo,
-      String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) {
-    String repoFullName = org + '/' + repo;
-    commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha,
-        commitStatusUpdate.getState(), (k, v) -> v);
-    return Response.ok().build();
-  }
+    @Override
+    public Response updateStatus(String bearer, String apiVersion, String org, String repo,
+            String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) {
+        String repoFullName = org + '/' + repo;
+        commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha,
+                commitStatusUpdate.getState(), (k, v) -> v);
+        return Response.ok().build();
+    }
 
-  @Override
-  public Response addComment(String bearer, String apiVersion, String organization, String repo,
-      int pullNumber, GithubPullRequestCommentRequest comment) {
-    return Response.ok().build();
-  }
+    @Override
+    public Response addComment(String bearer, String apiVersion, String organization, String repo,
+            int pullNumber, GithubPullRequestCommentRequest comment) {
+        return Response.ok().build();
+    }
 
-  @Override
-  public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
-      BaseAPIParameters params, String bearer) {
-    throw new UnsupportedOperationException("Unimplemented method 'getInstallations'");
-  }
+    @Override
+    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
+            BaseAPIParameters params, String bearer) {
+        throw new UnsupportedOperationException("Unimplemented method 'getInstallations'");
+    }
 
-  @Override
-  public GithubAccessToken getNewAccessToken(String bearer, String apiVersion,
-      String installationId) {
-    return GithubAccessToken.builder().setToken("gh-token-" + installationId)
-        .setExpiresAt(LocalDateTime.now().plusHours(1L)).build();
-  }
+    @Override
+    public GithubAccessToken getNewAccessToken(String bearer, String apiVersion,
+            String installationId) {
+        return GithubAccessToken.builder().setToken("gh-token-" + installationId)
+                .setExpiresAt(LocalDateTime.now().plusHours(1L)).build();
+    }
 
-  @Override
-  public Response getInstallationRepositories(BaseAPIParameters params, String bearer) {
-    throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'");
-  }
+    @Override
+    public Response getInstallationRepositories(BaseAPIParameters params, String bearer) {
+        throw new UnsupportedOperationException(
+                "Unimplemented method 'getInstallationRepositories'");
+    }
 
 }
-- 
GitLab


From b732bb984a66c88975465b1e9c7bd208c6a24e47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Wed, 23 Apr 2025 10:48:09 +0200
Subject: [PATCH 04/14] chore: adding missing license header

---
 .../git/eca/helper/GithubHelperTest.java                 | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index d3785ed8..9f7ee052 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -1,3 +1,12 @@
+/*
+ * 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.helper;
 
 import static org.mockito.Mockito.verify;
-- 
GitLab


From cb9c94edce5d8d7d7a883bbc8a5cdf74a76e2edb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Wed, 23 Apr 2025 11:26:24 +0200
Subject: [PATCH 05/14] chore: adding missing JavaDoc

---
 .../org/eclipsefoundation/git/eca/helper/GithubHelper.java | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 8f37d74f..d0471054 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -261,6 +261,13 @@ public class GithubHelper {
                         .setContext(webhooksConfig.github().context()).build());
     }
 
+    /**
+     * Posts a comment on a GitHub pull request when validation errors are found.
+     * If the error set is empty, no comment will be posted.
+     *
+     * @param request The GitHub webhook request containing pull request and repository information
+     * @param errors A set of error messages found during validation
+     */
     private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) {
         if (errors.isEmpty()) {
             return;
-- 
GitLab


From 1f720f825ba41a7af1e6a52e553a98f6c61bdc44 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Wed, 23 Apr 2025 12:29:49 +0200
Subject: [PATCH 06/14] feat: using Qute to create a more flexible message
 template

---
 .../git/eca/helper/GithubHelper.java          |  95 +++++++++++-----
 .../templates/github/validation_failure.md    |  19 ++++
 .../git/eca/helper/GithubHelperTest.java      | 105 +++++++++++++++---
 3 files changed, 176 insertions(+), 43 deletions(-)
 create mode 100644 src/main/resources/templates/github/validation_failure.md

diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index d0471054..d8f7c901 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -17,6 +17,8 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
+import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Supplier;
@@ -38,6 +40,7 @@ import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
 import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
 import org.eclipsefoundation.git.eca.model.Commit;
+import org.eclipsefoundation.git.eca.model.CommitStatus;
 import org.eclipsefoundation.git.eca.model.GitUser;
 import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.model.ValidationResponse;
@@ -56,7 +59,8 @@ import org.eclipsefoundation.utils.helper.DateTimeHelper;
 import org.eclipsefoundation.utils.helper.TransformationHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-
+import io.quarkus.qute.Location;
+import io.quarkus.qute.Template;
 import jakarta.enterprise.context.ApplicationScoped;
 import jakarta.inject.Inject;
 import jakarta.ws.rs.BadRequestException;
@@ -104,6 +108,9 @@ public class GithubHelper {
     @RestClient
     GithubAPI ghApi;
 
+    @Location("github/validation_failure.md")
+    Template failureMessage;
+
     /**
      * Using the wrapper and the passed unique information about a Github validation instance,
      * lookup or create the tracking request and validate the data.
@@ -261,32 +268,6 @@ public class GithubHelper {
                         .setContext(webhooksConfig.github().context()).build());
     }
 
-    /**
-     * Posts a comment on a GitHub pull request when validation errors are found.
-     * If the error set is empty, no comment will be posted.
-     *
-     * @param request The GitHub webhook request containing pull request and repository information
-     * @param errors A set of error messages found during validation
-     */
-    private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) {
-        if (errors.isEmpty()) {
-            return;
-        }
-        StringBuilder sb = new StringBuilder();
-        sb.append("The following errors were found in the validation of this pull request:\n");
-        for (String error : errors) {
-            sb.append("- ").append(error).append('\n');
-        }
-        sb.append("Please check the ECA validation status for more information.");
-
-        ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
-                request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
-                request.getPullRequest().getNumber(),
-                GithubPullRequestCommentRequest.builder().setBody(sb.toString())
-                        .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".")
-                        .build());
-    }
-
     /**
      * Process the current request and update the checks state to pending then success or failure.
      * Contains verbose TRACE logging for more info on the states of the validation for more
@@ -325,12 +306,30 @@ public class GithubHelper {
                     pr.getNumber(), GithubCommitStatuses.FAILURE);
             updateCommitStatus(request, GithubCommitStatuses.FAILURE);
             commentOnFailure(request,
+                    r.getCommits().entrySet().stream()
+                            .filter(e -> !e.getValue().getErrors().isEmpty()).map(e -> e.getKey())
+                            .map(hash -> findCommitByHash(vr, hash)).filter(Optional::isPresent)
+                            .map(Optional::get).map(c -> c.getAuthor().getName())
+                            .collect(Collectors.toSet()),
                     r.getCommits().values().stream().flatMap(c -> c.getErrors().stream())
                             .map(e -> e.getMessage()).collect(Collectors.toSet()));
         }
         return r;
     }
 
+    /**
+     * Searches for a commit in the validation request based on its hash value.
+     * 
+     * @param vr The validation request containing the list of commits to search
+     * @param hash The hash value to search for
+     * @return An Optional containing the matching Commit if found, or empty if not found
+     */
+    private Optional<Commit> findCommitByHash(ValidationRequest vr, String hash) {
+        return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash()))
+                .findFirst();
+    }
+
+
     /**
      * Shortcut method that will retrieve existing GH tracking info, create new entries if missing,
      * and will update the state of existing requests as well.
@@ -516,6 +515,48 @@ public class GithubHelper {
                         .setContext(webhooksConfig.github().context()).build());
     }
 
+    /**
+     * This method posts a comment to the pull request detailing the validation errors and
+     * mentioning the usernames that need to take action.
+     *
+     * @param request The request containing repository and pull request information
+     * @param usernames Set of GitHub usernames that need to take action
+     * @param errors Set of error messages describing the validation failures
+     *
+     * @throws IllegalArgumentException if the request parameter is null
+     */
+    private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames,
+            Set<String> errors) {
+        // This should never happen given the logic behind the getPassed, but adding this just in
+        // case that logic changes
+        if (errors.isEmpty()) {
+            return;
+        }
+
+        String ghBearerString = jwtHelper.getGhBearerString(request.getInstallation().getId());
+        String login = request.getRepository().getOwner().getLogin();
+        String repositoryName = request.getRepository().getName();
+        Integer pullRequestNumber = request.getPullRequest().getNumber();
+
+        GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest.builder()
+                .setBody(failureMessage.data("reasons", errors).data("usernames", usernames)
+                        .render())
+                .setCommitId(Objects.requireNonNull(request, "Request cannot be null")
+                        .getPullRequest().getHead().getSha())
+                .setPath(".").build();
+
+        LOGGER.trace("Generated access token for installation {}: {}",
+                request.getInstallation().getId(),
+                jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+
+        LOGGER.trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber,
+                TransformationHelper.formatLog(login),
+                TransformationHelper.formatLog(repositoryName), comment.getBody());
+
+        ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber,
+                comment);
+    }
+
     /**
      * Wraps a nullable value fetch to handle errors and will return null if the value can't be
      * retrieved.
diff --git a/src/main/resources/templates/github/validation_failure.md b/src/main/resources/templates/github/validation_failure.md
new file mode 100644
index 00000000..67f9243a
--- /dev/null
+++ b/src/main/resources/templates/github/validation_failure.md
@@ -0,0 +1,19 @@
+Hi{#for username in usernames} @{username}{/for} — thank you for your contribution!
+
+The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons:
+
+{#for reason in reasons}
+- {reason}
+{/for}
+
+To resolve this, please:
+
+1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca
+1. Ensure your GitHub username is linked to your Eclipse account
+1. Complete and submit the ECA form
+
+Once done, push a new commit (or rebase) to re-trigger the ECA validation.
+
+If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk).
+
+Thanks again for your contribution!
\ No newline at end of file
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 9f7ee052..10dc9456 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -16,6 +16,7 @@ import java.net.URI;
 import java.time.ZonedDateTime;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import org.eclipse.microprofile.rest.client.inject.RestClient;
@@ -29,6 +30,7 @@ import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.model.ValidationResponse;
 import org.eclipsefoundation.git.eca.namespace.APIStatusCode;
 import org.eclipsefoundation.git.eca.namespace.ProviderType;
+import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses;
 import org.eclipsefoundation.git.eca.service.ValidationService;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
@@ -52,22 +54,23 @@ class GithubHelperTest {
 
     @Test
     void testHandleGithubWebhookValidation_WithErrors() {
-        String commitHash = "abc123";
         String orgName = "test-org";
         String repoName = "test-repo";
         int prNumber = 42;
 
+        Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com");
+        Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com");
+
         GithubWebhookRequest request =
-                createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash);
-        Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com");
+                createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
+        List<Commit> commits = List.of(testCommit1, testCommit2);
 
         ValidationRequest vr = ValidationRequest.builder()
                 .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
-                .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit))
-                .build();
+                .setProvider(ProviderType.GITHUB).setCommits(commits).build();
 
-        ValidationResponse mockResponse =
-                createErrorValidationResponse(commitHash, "Missing ECA for user");
+        ValidationResponse mockResponse = createErrorValidationResponse(
+                Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error")));
 
         when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
                 .thenReturn(mockResponse);
@@ -75,15 +78,84 @@ class GithubHelperTest {
         helper.handleGithubWebhookValidation(request, vr, null);
 
         var expectedRequestBody = GithubPullRequestCommentRequest.builder()
-                .setBody("The following errors were found in the validation of this pull request:\n"
-                        + "- Missing ECA for user\n"
-                        + "Please check the ECA validation status for more information.")
+                .setBody(
+                        """
+                                Hi @testUser @testUser2 — thank you for your contribution!
+
+                                The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons:
+
+                                - Missing ECA for user
+                                - Another error
+
+                                To resolve this, please:
+
+                                1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca
+                                1. Ensure your GitHub username is linked to your Eclipse account
+                                1. Complete and submit the ECA form
+
+                                Once done, push a new commit (or rebase) to re-trigger the ECA validation.
+
+                                If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk).
+
+                                Thanks again for your contribution!""")
                 .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build();
 
         verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName),
                 Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody));
     }
 
+    @Test
+    void testHandleGithubWebhookValidation_Success() {
+        String orgName = "test-org";
+        String repoName = "test-repo";
+        int prNumber = 42;
+
+        Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com");
+
+        GithubWebhookRequest request =
+                createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
+        List<Commit> commits = List.of(testCommit);
+
+        ValidationRequest vr = ValidationRequest.builder()
+                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
+                .setProvider(ProviderType.GITHUB)
+                .setCommits(commits)
+                .build();
+
+        // Create a successful validation response with no errors
+        ValidationResponse mockResponse = ValidationResponse.builder()
+                .setTime(ZonedDateTime.now())
+                .setCommits(Map.of(testCommit.getHash(), CommitStatus.builder().build()))
+                .setTrackedProject(true)
+                .setStrictMode(true)
+                .build();
+
+        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
+                .thenReturn(mockResponse);
+
+        helper.handleGithubWebhookValidation(request, vr, null);
+
+        // Verify status was updated to success
+        verify(ghApi).updateStatus(
+            Mockito.anyString(),
+            Mockito.anyString(),
+            Mockito.eq(orgName),
+            Mockito.eq(repoName),
+            Mockito.eq(request.getPullRequest().getHead().getSha()),
+            Mockito.argThat(status -> status.getState().equals(GithubCommitStatuses.SUCCESS.toString()))
+        );
+
+        // Verify no comment was posted
+        verify(ghApi, Mockito.never()).addComment(
+            Mockito.anyString(),
+            Mockito.anyString(),
+            Mockito.anyString(),
+            Mockito.anyString(),
+            Mockito.anyInt(),
+            Mockito.any()
+        );
+    }
+
     /**
      * Creates a GitHub webhook request for testing purposes.
      * 
@@ -135,12 +207,13 @@ class GithubHelperTest {
      * @param errorMessage The error message
      * @return ValidationResponse instance
      */
-    private ValidationResponse createErrorValidationResponse(String commitHash,
-            String errorMessage) {
-        Map<String, CommitStatus> commits = new HashMap<>();
-        CommitStatus status = CommitStatus.builder().build();
-        status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR);
-        commits.put(commitHash, status);
+    private ValidationResponse createErrorValidationResponse(Map<Commit, List<String>> commitErrors) {
+        Map<String, CommitStatus> commits =
+                commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> {
+                    CommitStatus status = CommitStatus.builder().build();
+                    entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR));
+                    map.put(entry.getKey().getHash(), status);
+                }, HashMap::putAll);
 
         return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits)
                 .setTrackedProject(true).setStrictMode(true).build();
-- 
GitLab


From dfc43c1bcea997e16b96646e17a151c84ea67c7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Wed, 23 Apr 2025 17:14:12 +0200
Subject: [PATCH 07/14] chore: fix formatting

---
 .../git/eca/api/GithubAPI.java                |  45 +-
 .../GithubPullRequestCommentRequest.java      |   5 +-
 .../git/eca/helper/GithubHelper.java          | 440 ++++++++----------
 .../git/eca/model/ValidationResponse.java     |  21 +-
 .../git/eca/helper/GithubHelperTest.java      | 149 +++---
 .../git/eca/test/api/MockGithubAPI.java       |  70 +--
 6 files changed, 322 insertions(+), 408 deletions(-)

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index 74e36207..8806ca4a 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -1,8 +1,9 @@
 /**
  * Copyright (c) 2022 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/
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
  *
@@ -54,8 +55,7 @@ public interface GithubAPI {
     @GET
     @Path("repos/{org}/{repo}/pulls/{pullNumber}")
     public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("org") String organization, @PathParam("repo") String repo,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
             @PathParam("pullNumber") int pullNumber);
 
     /**
@@ -69,15 +69,12 @@ public interface GithubAPI {
      */
     @GET
     @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits")
-    public RestResponse<List<GithubCommit>> getCommits(
-            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("org") String organization, @PathParam("repo") String repo,
+    public RestResponse<List<GithubCommit>> getCommits(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
             @PathParam("pullNumber") int pullNumber);
 
     /**
-     * Posts an update to the Github API, using an access token to update a given pull requests
-     * commit status, targeted using the head sha.
+     * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha.
      * 
      * @param bearer authorization header value, access token for application with access to repo
      * @param apiVersion the version of the GH API to target when making the request
@@ -90,8 +87,7 @@ public interface GithubAPI {
     @POST
     @Path("repos/{org}/{repo}/statuses/{prHeadSha}")
     public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("org") String organization, @PathParam("repo") String repo,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
             @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate);
 
     /**
@@ -108,14 +104,12 @@ public interface GithubAPI {
     @POST
     @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
     public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("org") String organization, @PathParam("repo") String repo,
-            @PathParam("pullNumber") int pullNumber,
-            GithubPullRequestCommentRequest commentRequest);
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
+            @PathParam("pullNumber") int pullNumber, GithubPullRequestCommentRequest commentRequest);
 
     /**
-     * Requires a JWT bearer token for the application to retrieve installations for. Returns a list
-     * of installations for the given application.
+     * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given
+     * application.
      * 
      * @param params the general params for requests, including pagination
      * @param bearer JWT bearer token for the target application
@@ -123,13 +117,11 @@ public interface GithubAPI {
      */
     @GET
     @Path("app/installations")
-    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
-            @BeanParam BaseAPIParameters params,
+    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(@BeanParam BaseAPIParameters params,
             @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
     /**
-     * Retrieves an access token for a specific installation, given the applications JWT bearer and
-     * the api version.
+     * Retrieves an access token for a specific installation, given the applications JWT bearer and the api version.
      * 
      * @param bearer the authorization header value, should contain the apps signed JWT token
      * @param apiVersion the API version to target with the request
@@ -138,10 +130,8 @@ public interface GithubAPI {
      */
     @POST
     @Path("app/installations/{installationId}/access_tokens")
-    public GithubAccessToken getNewAccessToken(
-            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
-            @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("installationId") String installationId);
+    public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("installationId") String installationId);
 
     /**
      * Returns a list of repositories for the given installation.
@@ -152,7 +142,6 @@ public interface GithubAPI {
      */
     @GET
     @Path("installation/repositories")
-    public Response getInstallationRepositories(@BeanParam BaseAPIParameters params,
-            @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
+    public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
index 50b4a9d3..2690fe80 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
@@ -1,8 +1,9 @@
 /**
  * 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/
+ * 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
  */
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index d8f7c901..9d302726 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -1,8 +1,9 @@
 /**
  * Copyright (c) 2023 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/
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
  *
@@ -17,7 +18,6 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
-import java.util.Map.Entry;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -40,7 +40,6 @@ import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
 import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
 import org.eclipsefoundation.git.eca.model.Commit;
-import org.eclipsefoundation.git.eca.model.CommitStatus;
 import org.eclipsefoundation.git.eca.model.GitUser;
 import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.model.ValidationResponse;
@@ -71,15 +70,14 @@ import jakarta.ws.rs.core.MultivaluedMap;
 import jakarta.ws.rs.core.Response;
 
 /**
- * This class is used to adapt Github requests to the standard validation workflow in a way that
- * could be reused by both resource calls and scheduled tasks for revalidation.
+ * This class is used to adapt Github requests to the standard validation workflow in a way that could be reused by both resource calls and
+ * scheduled tasks for revalidation.
  */
 @ApplicationScoped
 public class GithubHelper {
     private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class);
 
-    private static final String VALIDATION_LOGGING_MESSAGE =
-            "Setting validation state for {}/#{} to {}";
+    private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}";
 
     @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
     String apiVersion;
@@ -112,32 +110,27 @@ public class GithubHelper {
     Template failureMessage;
 
     /**
-     * Using the wrapper and the passed unique information about a Github validation instance,
-     * lookup or create the tracking request and validate the data.
+     * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and
+     * validate the data.
      * 
      * @param wrapper the wrapper for the current request
      * @param org the name of the GH organization
      * @param repoName the slug of the repo that has the PR to be validated
      * @param prNo the PR number for the current request.
-     * @param forceRevalidation true if revalidation should be forced when there is no changes,
-     *        false otherwise.
-     * @return the validated response if it is a valid request, or throws a web exception if there
-     *         is a problem validating the request.
+     * @param forceRevalidation true if revalidation should be forced when there is no changes, false otherwise.
+     * @return the validated response if it is a valid request, or throws a web exception if there is a problem validating the request.
      */
-    public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org,
-            String repoName, Integer prNo, boolean forceRevalidation) {
+    public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, String repoName, Integer prNo,
+            boolean forceRevalidation) {
         // use logging helper to sanitize newlines as they aren't needed here
         String fullRepoName = getFullRepoName(org, repoName);
         // get the installation ID for the given repo if it exists, and if the PR noted exists
         String installationId = ghAppService.getInstallationForRepo(org, repoName);
-        Optional<PullRequest> prResponse =
-                ghAppService.getPullRequest(installationId, org, repoName, prNo);
+        Optional<PullRequest> prResponse = ghAppService.getPullRequest(installationId, org, repoName, prNo);
         if (StringUtils.isBlank(installationId)) {
-            throw new BadRequestException(
-                    "Could not find an ECA app installation for repo name: " + fullRepoName);
+            throw new BadRequestException("Could not find an ECA app installation for repo name: " + fullRepoName);
         } else if (prResponse.isEmpty()) {
-            throw new NotFoundException(
-                    String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
+            throw new NotFoundException(String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
         }
 
         // prepare the request for consumption
@@ -148,49 +141,39 @@ public class GithubHelper {
         List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList();
         // there should always be commits for a PR, but in case, lets check
         if (commitShas.isEmpty()) {
-            throw new BadRequestException(
-                    String.format("Could not find any commits for %s#%d", fullRepoName, prNo));
+            throw new BadRequestException(String.format("Could not find any commits for %s#%d", fullRepoName, prNo));
         }
         LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
 
-        // retrieve the webhook tracking info, or generate an entry to track this PR if it's
-        // missing.
-        GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper,
-                installationId, org, repoName, prResponse.get());
+        // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing.
+        GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName,
+                prResponse.get());
         if (updatedTracking == null) {
-            throw new ServerErrorException(
-                    "Error while attempting to revalidate request, try again later.",
+            throw new ServerErrorException("Error while attempting to revalidate request, try again later.",
                     Response.Status.INTERNAL_SERVER_ERROR);
         }
 
         // get the commit status of commits to use for checking historic validation
-        List<CommitValidationStatus> statuses =
-                validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas);
+        List<CommitValidationStatus> statuses = validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas);
         if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) {
-            throw new BadRequestException(
-                    "Cannot find validation history for current non-open PR, cannot provide validation status");
+            throw new BadRequestException("Cannot find validation history for current non-open PR, cannot provide validation status");
         }
 
-        // we only want to update/revalidate for open PRs, so don't do this check if the PR is
-        // merged/closed
+        // we only want to update/revalidate for open PRs, so don't do this check if the PR is merged/closed
         if (forceRevalidation) {
             LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo);
-            return handleGithubWebhookValidation(
-                    GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
-        } else if ("open".equalsIgnoreCase(prResponse.get().getState())
-                && commitShas.size() != statuses.size()) {
-            LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits",
-                    fullRepoName, prNo);
+            return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
+        } else if ("open".equalsIgnoreCase(prResponse.get().getState()) && commitShas.size() != statuses.size()) {
+            LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo);
             // using the updated tracking, perform the validation
-            return handleGithubWebhookValidation(
-                    GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
+            return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper);
         }
         return null;
     }
 
     /**
-     * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from
-     * the noted pull request using the installation ID for access/authorization.
+     * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the
+     * installation ID for access/authorization.
      * 
      * @param installationId the ECA app installation ID for the organization
      * @param repositoryFullName the full name of the repository where the PR resides
@@ -199,120 +182,125 @@ public class GithubHelper {
      * @return the populated validation request for the Github request information
      */
     @SuppressWarnings("null")
-    public ValidationRequest generateRequest(String installationId, String orgName,
-            String repositoryName, int pullRequestNumber, String repositoryUrl) {
+    public ValidationRequest generateRequest(String installationId, String orgName, String repositoryName, int pullRequestNumber,
+            String repositoryUrl) {
         checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
-        // get the commits that will be validated, don't cache as changes can come in too fast for
-        // it to
-        // be useful
+        // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful
         if (LOGGER.isTraceEnabled()) {
-            LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber,
-                    TransformationHelper.formatLog(orgName),
-                    TransformationHelper.formatLog(repositoryName));
+            LOGGER
+                    .trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, TransformationHelper.formatLog(orgName),
+                            TransformationHelper.formatLog(repositoryName));
         }
-        List<GithubCommit> commits =
-                middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId),
-                        apiVersion, orgName, repositoryName, pullRequestNumber));
+        List<GithubCommit> commits = middleware
+                .getAll(i -> ghApi
+                        .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, orgName, repositoryName, pullRequestNumber));
         if (LOGGER.isTraceEnabled()) {
-            LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(),
-                    pullRequestNumber, TransformationHelper.formatLog(orgName),
-                    TransformationHelper.formatLog(repositoryName));
+            LOGGER
+                    .trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber,
+                            TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName));
         }
 
         // set up the validation request from current data
-        return ValidationRequest.builder().setProvider(ProviderType.GITHUB)
-                .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true)
-                .setCommits(commits.stream()
-                        .map(c -> Commit.builder().setHash(c.getSha()).setAuthor(GitUser.builder()
-                                .setMail(getNullableString(
-                                        () -> c.getCommit().getAuthor().getEmail()))
-                                .setName(getNullableString(
-                                        () -> c.getCommit().getAuthor().getName()))
-                                .setExternalId(getNullableString(() -> c.getAuthor().getLogin()))
-                                .build())
-                                .setCommitter(GitUser.builder()
-                                        .setMail(getNullableString(
-                                                () -> c.getCommit().getCommitter().getEmail()))
-                                        .setName(getNullableString(
-                                                () -> c.getCommit().getCommitter().getName()))
-                                        .setExternalId(getNullableString(
-                                                () -> c.getCommitter().getLogin()))
+        return ValidationRequest
+                .builder()
+                .setProvider(ProviderType.GITHUB)
+                .setRepoUrl(URI.create(repositoryUrl))
+                .setStrictMode(true)
+                .setCommits(commits
+                        .stream()
+                        .map(c -> Commit
+                                .builder()
+                                .setHash(c.getSha())
+                                .setAuthor(GitUser
+                                        .builder()
+                                        .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail()))
+                                        .setName(getNullableString(() -> c.getCommit().getAuthor().getName()))
+                                        .setExternalId(getNullableString(() -> c.getAuthor().getLogin()))
+                                        .build())
+                                .setCommitter(GitUser
+                                        .builder()
+                                        .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail()))
+                                        .setName(getNullableString(() -> c.getCommit().getCommitter().getName()))
+                                        .setExternalId(getNullableString(() -> c.getCommitter().getLogin()))
                                         .build())
-                                .setParents(
-                                        c.getParents().stream().map(ParentCommit::getSha).toList())
+                                .setParents(c.getParents().stream().map(ParentCommit::getSha).toList())
                                 .build())
                         .toList())
                 .build();
     }
 
     /**
-     * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As
-     * commits need to pass branch rules including the existing ECA check, we don't need to check
-     * the commits here.
+     * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including
+     * the existing ECA check, we don't need to check the commits here.
      * 
-     * @param request information about the request from the GH webhook on what resource requested
-     *        revalidation. Used to target the commit status of the resources
+     * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit
+     * status of the resources
      */
     public void sendMergeQueueStatus(GithubWebhookRequest request) {
         if (request.getMergeGroup() == null) {
-            throw new BadRequestException(
-                    "Merge group object required in webhook request to send status for merge queue");
+            throw new BadRequestException("Merge group object required in webhook request to send status for merge queue");
         }
         // send the success status for the head SHA
-        ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()),
-                apiVersion, request.getRepository().getOwner().getLogin(),
-                request.getRepository().getName(), request.getMergeGroup().getHeadSha(),
-                GithubCommitStatusRequest.builder().setDescription(
-                        "Commits in merge group should be previously validated, auto-passing HEAD commit")
-                        .setState(GithubCommitStatuses.SUCCESS.toString())
-                        .setContext(webhooksConfig.github().context()).build());
+        ghApi
+                .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+                        request.getRepository().getOwner().getLogin(), request.getRepository().getName(),
+                        request.getMergeGroup().getHeadSha(),
+                        GithubCommitStatusRequest
+                                .builder()
+                                .setDescription("Commits in merge group should be previously validated, auto-passing HEAD commit")
+                                .setState(GithubCommitStatuses.SUCCESS.toString())
+                                .setContext(webhooksConfig.github().context())
+                                .build());
     }
 
     /**
-     * Process the current request and update the checks state to pending then success or failure.
-     * Contains verbose TRACE logging for more info on the states of the validation for more
-     * information
+     * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more
+     * info on the states of the validation for more information
      * 
-     * @param request information about the request from the GH webhook on what resource requested
-     *        revalidation. Used to target the commit status of the resources
-     * @param vr the pseudo request generated from the contextual webhook data. Used to make use of
-     *        existing validation logic.
+     * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit
+     * status of the resources
+     * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic.
      * @return true if the validation passed, false otherwise.
      */
-    public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request,
-            ValidationRequest vr, RequestWrapper wrapper) {
+    public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr, RequestWrapper wrapper) {
         // null check the pull request to make sure that someone didn't push a bad value
         PullRequest pr = request.getPullRequest();
         if (pr == null) {
-            throw new IllegalStateException(
-                    "Pull request should not be null when handling validation");
+            throw new IllegalStateException("Pull request should not be null when handling validation");
         }
 
         // update the status before processing
-        LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
-                pr.getNumber(), GithubCommitStatuses.PENDING);
+        LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.PENDING);
         updateCommitStatus(request, GithubCommitStatuses.PENDING);
 
         // validate the response
-        LOGGER.trace("Begining validation of request for {}/#{}",
-                request.getRepository().getFullName(), pr.getNumber());
+        LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(), pr.getNumber());
         ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
         if (r.getPassed()) {
-            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
-                    pr.getNumber(), GithubCommitStatuses.SUCCESS);
+            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.SUCCESS);
             updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
         } else {
-            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(),
-                    pr.getNumber(), GithubCommitStatuses.FAILURE);
+            LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.FAILURE);
             updateCommitStatus(request, GithubCommitStatuses.FAILURE);
             commentOnFailure(request,
-                    r.getCommits().entrySet().stream()
-                            .filter(e -> !e.getValue().getErrors().isEmpty()).map(e -> e.getKey())
-                            .map(hash -> findCommitByHash(vr, hash)).filter(Optional::isPresent)
-                            .map(Optional::get).map(c -> c.getAuthor().getName())
+                    r
+                            .getCommits()
+                            .entrySet()
+                            .stream()
+                            .filter(e -> !e.getValue().getErrors().isEmpty())
+                            .map(e -> e.getKey())
+                            .map(hash -> findCommitByHash(vr, hash))
+                            .filter(Optional::isPresent)
+                            .map(Optional::get)
+                            .map(c -> c.getAuthor().getName())
                             .collect(Collectors.toSet()),
-                    r.getCommits().values().stream().flatMap(c -> c.getErrors().stream())
-                            .map(e -> e.getMessage()).collect(Collectors.toSet()));
+                    r
+                            .getCommits()
+                            .values()
+                            .stream()
+                            .flatMap(c -> c.getErrors().stream())
+                            .map(e -> e.getMessage())
+                            .collect(Collectors.toSet()));
         }
         return r;
     }
@@ -325,41 +313,35 @@ public class GithubHelper {
      * @return An Optional containing the matching Commit if found, or empty if not found
      */
     private Optional<Commit> findCommitByHash(ValidationRequest vr, String hash) {
-        return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash()))
-                .findFirst();
+        return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash())).findFirst();
     }
 
-
     /**
-     * Shortcut method that will retrieve existing GH tracking info, create new entries if missing,
-     * and will update the state of existing requests as well.
+     * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing
+     * requests as well.
      * 
      * @param installationId the installation ID for the ECA app in the given repository
      * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
      * @param pr the pull request targeted by the validation request.
-     * @return a new or updated tracking object, or null if there was an error in saving the
-     *         information
+     * @return a new or updated tracking object, or null if there was an error in saving the information
      */
-    public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper,
-            String installationId, String orgName, String repositoryName, PullRequest pr) {
-        return updateGithubTrackingIfMissing(
-                wrapper, getExistingRequestInformation(wrapper, installationId, orgName,
-                        repositoryName, pr.getNumber()),
-                pr, installationId, orgName, repositoryName);
+    public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, String installationId, String orgName,
+            String repositoryName, PullRequest pr) {
+        return updateGithubTrackingIfMissing(wrapper,
+                getExistingRequestInformation(wrapper, installationId, orgName, repositoryName, pr.getNumber()), pr, installationId,
+                orgName, repositoryName);
     }
 
     /**
-     * Checks if the Github tracking is present for the current request, and if missing will
-     * generate a new record and save it.
+     * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it.
      * 
      * @param tracking the optional tracking entry for the current request
      * @param request the pull request that is being validated
      * @param installationId the ECA app installation ID for the current request
      * @param fullRepoName the full repo name for the validation request
      */
-    public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper,
-            Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId,
-            String orgName, String repositoryName) {
+    public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, Optional<GithubWebhookTracking> tracking,
+            PullRequest request, String installationId, String orgName, String repositoryName) {
         String fullRepoName = getFullRepoName(orgName, repositoryName);
         // if there is no tracking present, create the missing tracking and persist it
         GithubWebhookTracking updatedTracking;
@@ -372,9 +354,9 @@ public class GithubHelper {
             updatedTracking.setNeedsRevalidation(false);
             updatedTracking.setManualRevalidationCount(0);
             if (!"open".equalsIgnoreCase(request.getState())) {
-                LOGGER.warn(
-                        "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice",
-                        updatedTracking.getPullRequestNumber(), fullRepoName);
+                LOGGER
+                        .warn("The PR {} in {} is not in an open state, and will not be validated to follow our validation practice",
+                                updatedTracking.getPullRequestNumber(), fullRepoName);
             }
         } else {
             // uses the DB version as a base if available
@@ -385,96 +367,77 @@ public class GithubHelper {
         updatedTracking.setHeadSha(request.getHead().getSha());
 
         // save the data, and log on its success or failure
-        List<GithubWebhookTracking> savedTracking =
-                dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)),
-                        Arrays.asList(updatedTracking));
+        List<GithubWebhookTracking> savedTracking = dao
+                .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(updatedTracking));
         if (savedTracking.isEmpty()) {
-            LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}",
-                    fullRepoName, request.getNumber());
+            LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber());
             return null;
         }
         // return the updated tracking when successful
-        LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}",
-                fullRepoName, request.getNumber());
+        LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber());
         return savedTracking.get(0);
     }
 
     /**
-     * Attempts to retrieve a webhook tracking record given the installation, repository, and pull
-     * request number.
+     * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number.
      * 
      * @param installationId the installation ID for the ECA app in the given repository
      * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
      * @param pullRequestNumber the pull request number that is being processed currently
      * @return the webhook tracking record if it can be found, or an empty optional.
      */
-    public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper,
-            String installationId, String orgName, String repositoryName, int pullRequestNumber) {
+    public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, String installationId, String orgName,
+            String repositoryName, int pullRequestNumber) {
         checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber);
         String repositoryFullName = getFullRepoName(orgName, repositoryName);
         MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
         params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId);
         params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName);
-        params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW,
-                Integer.toString(pullRequestNumber));
-        return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params))
-                .stream().findFirst();
+        params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber));
+        return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst();
     }
 
     /**
-     * Using the configured GitHub application JWT and application ID, fetches all active
-     * installations and updates the DB cache containing the installation data. After all records
-     * are updated, the starting timestamp is used to delete stale records that were updated before
+     * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing
+     * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before
      * the starting time.
      * 
-     * This does not use locking, but with the way that updates are done to look for long stale
-     * entries, multiple concurrent runs would not lead to a loss of data/integrity.
+     * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not
+     * lead to a loss of data/integrity.
      */
     public void updateAppInstallationRecords() {
         // get the installations for the currently configured app
-        List<GithubApplicationInstallationData> installations =
-                middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt()));
-        // check that there are installations, none may indicate an issue, and better to fail
-        // pessimistically
+        List<GithubApplicationInstallationData> installations = middleware
+                .getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt()));
+        // check that there are installations, none may indicate an issue, and better to fail pessimistically
         if (installations.isEmpty()) {
-            LOGGER.warn(
-                    "Did not find any installations for the currently configured Github application");
+            LOGGER.warn("Did not find any installations for the currently configured Github application");
             return;
         }
         // trace log the installations for more context
         LOGGER.debug("Found {} installations to cache", installations.size());
 
         // create a common timestamp that looks for entries stale for more than a day
-        Date startingTimestamp =
-                new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli());
+        Date startingTimestamp = new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli());
         // from installations, build records and start the processing for each entry
-        List<GithubApplicationInstallation> installationRecords =
-                installations.stream().map(this::processInstallation).toList();
-
-        // once records are prepared, persist them back to the database with updates where necessary
-        // as
-        // a batch
-        RequestWrapper wrap = new FlatRequestWrapper(
-                URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
-        List<GithubApplicationInstallation> repoRecords =
-                dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)),
-                        installationRecords);
+        List<GithubApplicationInstallation> installationRecords = installations.stream().map(this::processInstallation).toList();
+
+        // once records are prepared, persist them back to the database with updates where necessary as a batch
+        RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
+        List<GithubApplicationInstallation> repoRecords = dao
+                .add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), installationRecords);
         if (repoRecords.size() != installationRecords.size()) {
-            LOGGER.warn(
-                    "Background update to installation records had a size mismatch, cleaning will be skipped for this run");
+            LOGGER.warn("Background update to installation records had a size mismatch, cleaning will be skipped for this run");
             return;
         }
 
         // build query to do cleanup of stale records
         MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-        params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
-                Integer.toString(webhooksConfig.github().appId()));
-        params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW,
-                DateTimeHelper.toRFC3339(startingTimestamp));
+        params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId()));
+        params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, DateTimeHelper.toRFC3339(startingTimestamp));
 
         // run the delete call, removing stale entries
-        dao.delete(
-                new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
+        dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
     }
 
     /**
@@ -498,26 +461,28 @@ public class GithubHelper {
         PullRequest pr = request.getPullRequest();
         if (pr == null) {
             // should not be reachable, but for safety
-            throw new IllegalStateException(
-                    "Pull request should not be null when handling validation");
+            throw new IllegalStateException("Pull request should not be null when handling validation");
         }
 
-        LOGGER.trace("Generated access token for installation {}: {}",
-                request.getInstallation().getId(),
-                jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
-        ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()),
-                apiVersion, request.getRepository().getOwner().getLogin(),
-                request.getRepository().getName(), pr.getHead().getSha(),
-                GithubCommitStatusRequest.builder().setDescription(state.getMessage())
-                        .setState(state.toString())
-                        .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
-                                + request.getRepository().getFullName() + '/' + pr.getNumber())
-                        .setContext(webhooksConfig.github().context()).build());
+        LOGGER
+                .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
+                        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        ghApi
+                .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+                        request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(),
+                        GithubCommitStatusRequest
+                                .builder()
+                                .setDescription(state.getMessage())
+                                .setState(state.toString())
+                                .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
+                                        + request.getRepository().getFullName() + '/' + pr.getNumber())
+                                .setContext(webhooksConfig.github().context())
+                                .build());
     }
 
     /**
-     * This method posts a comment to the pull request detailing the validation errors and
-     * mentioning the usernames that need to take action.
+     * This method posts a comment to the pull request detailing the validation errors and mentioning the usernames that need to take
+     * action.
      *
      * @param request The request containing repository and pull request information
      * @param usernames Set of GitHub usernames that need to take action
@@ -525,8 +490,7 @@ public class GithubHelper {
      *
      * @throws IllegalArgumentException if the request parameter is null
      */
-    private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames,
-            Set<String> errors) {
+    private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, Set<String> errors) {
         // This should never happen given the logic behind the getPassed, but adding this just in
         // case that logic changes
         if (errors.isEmpty()) {
@@ -538,28 +502,26 @@ public class GithubHelper {
         String repositoryName = request.getRepository().getName();
         Integer pullRequestNumber = request.getPullRequest().getNumber();
 
-        GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest.builder()
-                .setBody(failureMessage.data("reasons", errors).data("usernames", usernames)
-                        .render())
-                .setCommitId(Objects.requireNonNull(request, "Request cannot be null")
-                        .getPullRequest().getHead().getSha())
-                .setPath(".").build();
+        GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest
+                .builder()
+                .setBody(failureMessage.data("reasons", errors).data("usernames", usernames).render())
+                .setCommitId(Objects.requireNonNull(request, "Request cannot be null").getPullRequest().getHead().getSha())
+                .setPath(".")
+                .build();
 
-        LOGGER.trace("Generated access token for installation {}: {}",
-                request.getInstallation().getId(),
-                jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        LOGGER
+                .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
+                        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
 
-        LOGGER.trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber,
-                TransformationHelper.formatLog(login),
-                TransformationHelper.formatLog(repositoryName), comment.getBody());
+        LOGGER
+                .trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login),
+                        TransformationHelper.formatLog(repositoryName), comment.getBody());
 
-        ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber,
-                comment);
+        ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment);
     }
 
     /**
-     * Wraps a nullable value fetch to handle errors and will return null if the value can't be
-     * retrieved.
+     * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved.
      * 
      * @param supplier the method with potentially nullable values
      * @return the value if it can be found, or null
@@ -581,8 +543,7 @@ public class GithubHelper {
      * @param pullRequestNumber the pull request number that is being processed currently
      * @throws BadRequestException if at least one of the parameters is in an invalid state.
      */
-    private void checkRequestParameters(String installationId, String org, String repoName,
-            int pullRequestNumber) {
+    private void checkRequestParameters(String installationId, String org, String repoName, int pullRequestNumber) {
         List<String> missingFields = new ArrayList<>();
         if (StringUtils.isBlank(installationId)) {
             missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
@@ -599,33 +560,27 @@ public class GithubHelper {
 
         // throw exception if some fields are missing as we can't continue to process the request
         if (!missingFields.isEmpty()) {
-            throw new BadRequestException("Missing fields in order to prepare request: "
-                    + StringUtils.join(missingFields, ' '));
+            throw new BadRequestException("Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' '));
         }
     }
 
     /**
-     * Converts the raw installation data from Github into a short record to be persisted to
-     * database as a form of persistent caching. Checks database for existing record, and returns
-     * record with a touched date for existing entries.
+     * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching.
+     * Checks database for existing record, and returns record with a touched date for existing entries.
      * 
      * @param ghInstallation raw Github installation record for current application
      * @return the new or updated installation record to be persisted to the database.
      */
-    private GithubApplicationInstallation processInstallation(
-            GithubApplicationInstallationData ghInstallation) {
-        RequestWrapper wrap = new FlatRequestWrapper(
-                URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
+    private GithubApplicationInstallation processInstallation(GithubApplicationInstallationData ghInstallation) {
+        RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations"));
         // build the lookup query for the current installation record
         MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
-        params.add(GitEcaParameterNames.APPLICATION_ID_RAW,
-                Integer.toString(webhooksConfig.github().appId()));
-        params.add(GitEcaParameterNames.INSTALLATION_ID_RAW,
-                Integer.toString(ghInstallation.getId()));
+        params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId()));
+        params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId()));
 
         // lookup existing records in the database
-        List<GithubApplicationInstallation> existingRecords = dao.get(
-                new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
+        List<GithubApplicationInstallation> existingRecords = dao
+                .get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params));
 
         // check for existing entry, creating if missing
         GithubApplicationInstallation installation;
@@ -637,19 +592,16 @@ public class GithubHelper {
             installation = existingRecords.get(0);
         }
         // update the basic stats to handle renames, and update last updated time
-        // login is technically nullable, so this might be missing. This is best we can do, as we
-        // can't
-        // look up by id
-        installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin())
-                ? ghInstallation.getAccount().getLogin()
-                : "UNKNOWN");
+        // login is technically nullable, so this might be missing. This is best we can do, as we can't look up by id
+        installation
+                .setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) ? ghInstallation.getAccount().getLogin()
+                        : "UNKNOWN");
         installation.setLastUpdated(new Date());
         return installation;
     }
 
     /**
-     * Retrieves the full repo name for a given org and repo name, used for storage and legacy
-     * support.
+     * Retrieves the full repo name for a given org and repo name, used for storage and legacy support.
      *
      * @param org organization name being targeted
      * @param repo name of repo being targeted
diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
index 2a5ea7a3..9d27dec4 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java
@@ -1,8 +1,9 @@
 /*********************************************************************
  * Copyright (c) 2020 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/
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
  *
@@ -59,11 +60,9 @@ public abstract class ValidationResponse {
     /** @param error message to add to the API response */
     public void addError(String hash, String error, APIStatusCode code) {
         if (this.getTrackedProject() || this.getStrictMode()) {
-            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build())
-                    .addError(error, code);
+            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addError(error, code);
         } else {
-            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build())
-                    .addWarning(error, code);
+            getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addWarning(error, code);
         }
     }
 
@@ -74,8 +73,7 @@ public abstract class ValidationResponse {
     /**
      * Converts the APIResponse to a web response with appropriate status.
      *
-     * @return a web response with status {@link Status.OK} if the commits pass validation,
-     *         {@link Status.FORBIDDEN} otherwise.
+     * @return a web response with status {@link Status.OK} if the commits pass validation, {@link Status.FORBIDDEN} otherwise.
      */
     public Response toResponse() {
         // update error count before returning
@@ -87,8 +85,11 @@ public abstract class ValidationResponse {
     }
 
     public static Builder builder() {
-        return new AutoValue_ValidationResponse.Builder().setStrictMode(false)
-                .setTrackedProject(false).setTime(ZonedDateTime.now()).setCommits(new HashMap<>());
+        return new AutoValue_ValidationResponse.Builder()
+                .setStrictMode(false)
+                .setTrackedProject(false)
+                .setTime(ZonedDateTime.now())
+                .setCommits(new HashMap<>());
     }
 
     @AutoValue.Builder
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 10dc9456..14a65937 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -1,8 +1,9 @@
-/*
+/**
  * 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/
+ * 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
  */
@@ -30,7 +31,6 @@ import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.model.ValidationResponse;
 import org.eclipsefoundation.git.eca.namespace.APIStatusCode;
 import org.eclipsefoundation.git.eca.namespace.ProviderType;
-import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses;
 import org.eclipsefoundation.git.eca.service.ValidationService;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
@@ -61,23 +61,25 @@ class GithubHelperTest {
         Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com");
         Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com");
 
-        GithubWebhookRequest request =
-                createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
+        GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
         List<Commit> commits = List.of(testCommit1, testCommit2);
 
-        ValidationRequest vr = ValidationRequest.builder()
+        ValidationRequest vr = ValidationRequest
+                .builder()
                 .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
-                .setProvider(ProviderType.GITHUB).setCommits(commits).build();
+                .setProvider(ProviderType.GITHUB)
+                .setCommits(commits)
+                .build();
 
         ValidationResponse mockResponse = createErrorValidationResponse(
                 Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error")));
 
-        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
-                .thenReturn(mockResponse);
+        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse);
 
         helper.handleGithubWebhookValidation(request, vr, null);
 
-        var expectedRequestBody = GithubPullRequestCommentRequest.builder()
+        var expectedRequestBody = GithubPullRequestCommentRequest
+                .builder()
                 .setBody(
                         """
                                 Hi @testUser @testUser2 — thank you for your contribution!
@@ -98,62 +100,13 @@ class GithubHelperTest {
                                 If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk).
 
                                 Thanks again for your contribution!""")
-                .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build();
-
-        verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName),
-                Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody));
-    }
-
-    @Test
-    void testHandleGithubWebhookValidation_Success() {
-        String orgName = "test-org";
-        String repoName = "test-repo";
-        int prNumber = 42;
-
-        Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com");
-
-        GithubWebhookRequest request =
-                createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
-        List<Commit> commits = List.of(testCommit);
-
-        ValidationRequest vr = ValidationRequest.builder()
-                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
-                .setProvider(ProviderType.GITHUB)
-                .setCommits(commits)
+                .setCommitId(request.getPullRequest().getHead().getSha())
+                .setPath(".")
                 .build();
 
-        // Create a successful validation response with no errors
-        ValidationResponse mockResponse = ValidationResponse.builder()
-                .setTime(ZonedDateTime.now())
-                .setCommits(Map.of(testCommit.getHash(), CommitStatus.builder().build()))
-                .setTrackedProject(true)
-                .setStrictMode(true)
-                .build();
-
-        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any()))
-                .thenReturn(mockResponse);
-
-        helper.handleGithubWebhookValidation(request, vr, null);
-
-        // Verify status was updated to success
-        verify(ghApi).updateStatus(
-            Mockito.anyString(),
-            Mockito.anyString(),
-            Mockito.eq(orgName),
-            Mockito.eq(repoName),
-            Mockito.eq(request.getPullRequest().getHead().getSha()),
-            Mockito.argThat(status -> status.getState().equals(GithubCommitStatuses.SUCCESS.toString()))
-        );
-
-        // Verify no comment was posted
-        verify(ghApi, Mockito.never()).addComment(
-            Mockito.anyString(),
-            Mockito.anyString(),
-            Mockito.anyString(),
-            Mockito.anyString(),
-            Mockito.anyInt(),
-            Mockito.any()
-        );
+        verify(ghApi)
+                .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber),
+                        Mockito.eq(expectedRequestBody));
     }
 
     /**
@@ -166,21 +119,23 @@ class GithubHelperTest {
      * @param commitSha The commit SHA
      * @return GithubWebhookRequest instance
      */
-    private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName,
-            String orgName, int prNumber, String commitSha) {
-        return GithubWebhookRequest.builder()
-                .setInstallation(
-                        GithubWebhookRequest.Installation.builder().setId(installationId).build())
-                .setRepository(
-                        GithubWebhookRequest.Repository.builder().setName(repoName)
-                                .setFullName(orgName + "/" + repoName)
-                                .setOwner(GithubWebhookRequest.RepositoryOwner.builder()
-                                        .setLogin(orgName).build())
-                                .setHtmlUrl("https://github.com/" + orgName + "/" + repoName)
-                                .build())
-                .setPullRequest(GithubWebhookRequest.PullRequest.builder().setNumber(prNumber)
-                        .setState("open").setHead(GithubWebhookRequest.PullRequestHead.builder()
-                                .setSha(commitSha).build())
+    private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, String orgName, int prNumber,
+            String commitSha) {
+        return GithubWebhookRequest
+                .builder()
+                .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build())
+                .setRepository(GithubWebhookRequest.Repository
+                        .builder()
+                        .setName(repoName)
+                        .setFullName(orgName + "/" + repoName)
+                        .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build())
+                        .setHtmlUrl("https://github.com/" + orgName + "/" + repoName)
+                        .build())
+                .setPullRequest(GithubWebhookRequest.PullRequest
+                        .builder()
+                        .setNumber(prNumber)
+                        .setState("open")
+                        .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build())
                         .build())
                 .build();
     }
@@ -195,9 +150,15 @@ class GithubHelperTest {
      */
     private Commit createTestCommit(String commitHash, String userName, String userEmail) {
         GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build();
-        return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser)
-                .setSubject("Test commit").setBody("Test commit body")
-                .setParents(Collections.emptyList()).build();
+        return Commit
+                .builder()
+                .setHash(commitHash)
+                .setAuthor(testUser)
+                .setCommitter(testUser)
+                .setSubject("Test commit")
+                .setBody("Test commit body")
+                .setParents(Collections.emptyList())
+                .build();
     }
 
     /**
@@ -208,15 +169,19 @@ class GithubHelperTest {
      * @return ValidationResponse instance
      */
     private ValidationResponse createErrorValidationResponse(Map<Commit, List<String>> commitErrors) {
-        Map<String, CommitStatus> commits =
-                commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> {
-                    CommitStatus status = CommitStatus.builder().build();
-                    entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR));
-                    map.put(entry.getKey().getHash(), status);
-                }, HashMap::putAll);
-
-        return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits)
-                .setTrackedProject(true).setStrictMode(true).build();
+        Map<String, CommitStatus> commits = commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> {
+            CommitStatus status = CommitStatus.builder().build();
+            entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR));
+            map.put(entry.getKey().getHash(), status);
+        }, HashMap::putAll);
+
+        return ValidationResponse
+                .builder()
+                .setTime(ZonedDateTime.now())
+                .setCommits(commits)
+                .setTrackedProject(true)
+                .setStrictMode(true)
+                .build();
     }
 
 }
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index 5f146677..ddeac400 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -1,8 +1,9 @@
 /*********************************************************************
  * Copyright (c) 2023 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/
+ * 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: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org>
  *
@@ -47,29 +48,39 @@ public class MockGithubAPI implements GithubAPI {
     public MockGithubAPI() {
         this.commitStatuses = new HashMap<>();
         this.commits = new HashMap<>();
-        this.commits.put("eclipsefdn/sample",
-                Map.of(42, Arrays.asList(GithubCommit.builder().setSha("sha-1234")
-                        .setAuthor(GithubCommitUser.builder().setLogin("testuser").build())
-                        .setCommitter(GithubCommitUser.builder().setLogin("testuser").build())
-                        .setParents(Collections.emptyList())
-                        .setCommit(CommitData.builder()
-                                .setAuthor(GitCommitUser.builder().setName("The Wizard")
-                                        .setEmail("code.wiz@important.co").build())
-                                .setCommitter(GitCommitUser.builder().setName("The Wizard")
-                                        .setEmail("code.wiz@important.co").build())
-                                .build())
-                        .build())));
+        this.commits
+                .put("eclipsefdn/sample",
+                        Map
+                                .of(42, Arrays
+                                        .asList(GithubCommit
+                                                .builder()
+                                                .setSha("sha-1234")
+                                                .setAuthor(GithubCommitUser.builder().setLogin("testuser").build())
+                                                .setCommitter(GithubCommitUser.builder().setLogin("testuser").build())
+                                                .setParents(Collections.emptyList())
+                                                .setCommit(CommitData
+                                                        .builder()
+                                                        .setAuthor(GitCommitUser
+                                                                .builder()
+                                                                .setName("The Wizard")
+                                                                .setEmail("code.wiz@important.co")
+                                                                .build())
+                                                        .setCommitter(GitCommitUser
+                                                                .builder()
+                                                                .setName("The Wizard")
+                                                                .setEmail("code.wiz@important.co")
+                                                                .build())
+                                                        .build())
+                                                .build())));
     }
 
     @Override
-    public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo,
-            int pullNumber) {
+    public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, int pullNumber) {
         return PullRequest.builder().build();
     }
 
     @Override
-    public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org,
-            String repo, int pullNumber) {
+    public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, String repo, int pullNumber) {
         String repoFullName = org + '/' + repo;
         List<GithubCommit> results = commits.get(repoFullName).get(pullNumber);
         if (results == null || !results.isEmpty()) {
@@ -79,37 +90,32 @@ public class MockGithubAPI implements GithubAPI {
     }
 
     @Override
-    public Response updateStatus(String bearer, String apiVersion, String org, String repo,
-            String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) {
+    public Response updateStatus(String bearer, String apiVersion, String org, String repo, String prHeadSha,
+            GithubCommitStatusRequest commitStatusUpdate) {
         String repoFullName = org + '/' + repo;
-        commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha,
-                commitStatusUpdate.getState(), (k, v) -> v);
+        commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, commitStatusUpdate.getState(), (k, v) -> v);
         return Response.ok().build();
     }
 
     @Override
-    public Response addComment(String bearer, String apiVersion, String organization, String repo,
-            int pullNumber, GithubPullRequestCommentRequest comment) {
+    public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber,
+            GithubPullRequestCommentRequest comment) {
         return Response.ok().build();
     }
 
     @Override
-    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(
-            BaseAPIParameters params, String bearer) {
+    public RestResponse<List<GithubApplicationInstallationData>> getInstallations(BaseAPIParameters params, String bearer) {
         throw new UnsupportedOperationException("Unimplemented method 'getInstallations'");
     }
 
     @Override
-    public GithubAccessToken getNewAccessToken(String bearer, String apiVersion,
-            String installationId) {
-        return GithubAccessToken.builder().setToken("gh-token-" + installationId)
-                .setExpiresAt(LocalDateTime.now().plusHours(1L)).build();
+    public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, String installationId) {
+        return GithubAccessToken.builder().setToken("gh-token-" + installationId).setExpiresAt(LocalDateTime.now().plusHours(1L)).build();
     }
 
     @Override
     public Response getInstallationRepositories(BaseAPIParameters params, String bearer) {
-        throw new UnsupportedOperationException(
-                "Unimplemented method 'getInstallationRepositories'");
+        throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'");
     }
 
 }
-- 
GitLab


From 51241f82e1246c65eb254887467ba90abf9105cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Thu, 24 Apr 2025 15:58:00 +0200
Subject: [PATCH 08/14] feat: checking for existing comments before commenting
 one more time

---
 .../git/eca/api/GithubAPI.java                | 57 ++++++++++++++++++-
 .../api/models/GithubPullRequestComment.java  | 53 +++++++++++++++++
 .../git/eca/helper/GithubHelper.java          | 27 +++++++--
 .../git/eca/helper/GithubHelperTest.java      | 49 ++++++++++++++++
 .../git/eca/test/api/MockGithubAPI.java       | 19 +++++--
 5 files changed, 193 insertions(+), 12 deletions(-)
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index 8806ca4a..418bc631 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -11,7 +11,9 @@
  */
 package org.eclipsefoundation.git.eca.api;
 
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 
 import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
 import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters;
@@ -19,17 +21,20 @@ import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationData;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
-import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment;
 import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.jboss.resteasy.reactive.RestResponse;
 
 import jakarta.ws.rs.BeanParam;
 import jakarta.ws.rs.GET;
 import jakarta.ws.rs.HeaderParam;
 import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PATCH;
 import jakarta.ws.rs.Path;
 import jakarta.ws.rs.PathParam;
 import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
 import jakarta.ws.rs.core.HttpHeaders;
 import jakarta.ws.rs.core.Response;
 
@@ -107,6 +112,24 @@ public interface GithubAPI {
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
             @PathParam("pullNumber") int pullNumber, GithubPullRequestCommentRequest commentRequest);
 
+    /**
+     * Lists comments on a pull request.
+     *
+     * @param bearer authorization header value, access token for application with access to repo
+     * @param apiVersion the version of the GH API to target when making the request
+     * @param organization the organization that owns the targeted repo
+     * @param repo the repo name that is being targeted
+     * @param pullNumber the number of the pull request
+     * @param perPage number of items to return per page, max 100
+     * @param page page number to return
+     * @return list of comments on the pull request
+     */
+    @GET
+    @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
+    public RestResponse<List<GithubPullRequestComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
+            @PathParam("pullNumber") int pullNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page);
+
     /**
      * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given
      * application.
@@ -144,4 +167,36 @@ public interface GithubAPI {
     @Path("installation/repositories")
     public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
+    /**
+     * Recursively fetches all comments from a pull request by handling pagination manually.
+     *
+     * @param bearer GitHub bearer token
+     * @param apiVersion GitHub API version
+     * @param org organization name
+     * @param repo repository name
+     * @param prNumber pull request number
+     * @return List of all comments from the pull request
+     */
+    default List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo,
+            int prNumber) {
+        List<GithubPullRequestComment> allComments = new ArrayList<>();
+        int page = 1;
+        int perPage = 100; // GitHub's maximum items per page
+
+        // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've
+        // reached the end
+        while (true) {
+            RestResponse<List<GithubPullRequestComment>> response = getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
+
+            List<GithubPullRequestComment> comments = response.getEntity();
+            if (Objects.isNull(comments) || comments.isEmpty()) {
+                break;
+            }
+
+            allComments.addAll(comments);
+            page++;
+        }
+
+        return allComments;
+    }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
new file mode 100644
index 00000000..fe0b5ff1
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
@@ -0,0 +1,53 @@
+/*
+ * 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.api.models;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+@JsonDeserialize(builder = AutoValue_GithubPullRequestComment.Builder.class)
+public abstract class GithubPullRequestComment {
+    public abstract String getBody();
+    public abstract int getId();
+    public abstract GithubUser getUser();
+
+    @AutoValue
+    @JsonDeserialize(builder = AutoValue_GithubPullRequestComment_GithubUser.Builder.class)
+    public abstract static class GithubUser {
+        public abstract int getId();
+        
+        public static Builder builder() {
+            return new AutoValue_GithubPullRequestComment_GithubUser.Builder();
+        }
+
+        @AutoValue.Builder
+        @JsonPOJOBuilder(withPrefix = "set")
+        public abstract static class Builder {
+            public abstract Builder setId(int id);
+            public abstract GithubUser build();
+        }
+    }
+
+    public static Builder builder() {
+        return new AutoValue_GithubPullRequestComment.Builder();
+    }
+
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+        public abstract Builder setBody(String body);
+        public abstract Builder setId(int id);
+        public abstract Builder setUser(GithubUser user);
+        public abstract GithubPullRequestComment build();
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 9d302726..073032ed 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -32,6 +32,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment;
 import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
@@ -145,7 +146,7 @@ public class GithubHelper {
         }
         LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
 
-        // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing.
+        // retrieve the webhook tracking info, or generate an entry to track this PR
         GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName,
                 prResponse.get());
         if (updatedTracking == null) {
@@ -491,8 +492,7 @@ public class GithubHelper {
      * @throws IllegalArgumentException if the request parameter is null
      */
     private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, Set<String> errors) {
-        // This should never happen given the logic behind the getPassed, but adding this just in
-        // case that logic changes
+        // This should never happen given the logic behind the getPassed, but adding this just in case that logic changes
         if (errors.isEmpty()) {
             return;
         }
@@ -502,10 +502,27 @@ public class GithubHelper {
         String repositoryName = request.getRepository().getName();
         Integer pullRequestNumber = request.getPullRequest().getNumber();
 
+        // Get existing comments using pagination
+        List<GithubPullRequestComment> comments = ghApi
+                .getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber);
+
+        Set<String> nonMentionedUsers = usernames
+                .stream()
+                .filter(username -> comments
+                        .stream()
+                        .noneMatch(comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username))))
+                .collect(Collectors.toSet());
+
+        // If all the users have already been mentioned, skip commenting
+        if (nonMentionedUsers.isEmpty()) {
+            LOGGER.debug("All users have already been mentioned in the comments, skipping comment creation.");
+            return;
+        }
+
         GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest
                 .builder()
                 .setBody(failureMessage.data("reasons", errors).data("usernames", usernames).render())
-                .setCommitId(Objects.requireNonNull(request, "Request cannot be null").getPullRequest().getHead().getSha())
+                .setCommitId(request.getPullRequest().getHead().getSha())
                 .setPath(".")
                 .build();
 
@@ -514,7 +531,7 @@ public class GithubHelper {
                         jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
 
         LOGGER
-                .trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login),
+                .trace("Adding new comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login),
                         TransformationHelper.formatLog(repositoryName), comment.getBody());
 
         ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment);
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 14a65937..79da0acc 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -54,6 +54,7 @@ class GithubHelperTest {
 
     @Test
     void testHandleGithubWebhookValidation_WithErrors() {
+        // Set up test data for the PR
         String orgName = "test-org";
         String repoName = "test-repo";
         int prNumber = 42;
@@ -61,9 +62,11 @@ class GithubHelperTest {
         Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com");
         Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com");
 
+        // Create webhook request simulating a GitHub PR event
         GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
         List<Commit> commits = List.of(testCommit1, testCommit2);
 
+        // Build a validation request as the service would receive it
         ValidationRequest vr = ValidationRequest
                 .builder()
                 .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
@@ -71,13 +74,17 @@ class GithubHelperTest {
                 .setCommits(commits)
                 .build();
 
+        // Create a mock validation response with multiple errors
         ValidationResponse mockResponse = createErrorValidationResponse(
                 Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error")));
 
+        // Mock validation service to return our error response
         when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse);
 
+        // Execute the validation handler
         helper.handleGithubWebhookValidation(request, vr, null);
 
+        // Prepare the expected error comment that should be posted to GitHub
         var expectedRequestBody = GithubPullRequestCommentRequest
                 .builder()
                 .setBody(
@@ -104,11 +111,53 @@ class GithubHelperTest {
                 .setPath(".")
                 .build();
 
+        // Verify that the correct error comment was added to the PR
         verify(ghApi)
                 .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber),
                         Mockito.eq(expectedRequestBody));
     }
 
+    @Test
+    void testHandleGithubWebhookValidation_NoErrors() {
+        // Set up test data for the PR
+        String orgName = "test-org";
+        String repoName = "test-repo";
+        int prNumber = 42;
+
+        Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com");
+
+        // Create webhook request simulating a GitHub PR event
+        GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
+        List<Commit> commits = List.of(testCommit);
+
+        // Build a validation request as the service would receive it
+        ValidationRequest vr = ValidationRequest
+                .builder()
+                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
+                .setProvider(ProviderType.GITHUB)
+                .setCommits(commits)
+                .build();
+
+        // Create a mock successful validation response with no errors
+        ValidationResponse mockResponse = ValidationResponse
+                .builder()
+                .setStrictMode(false)
+                .setTrackedProject(true)
+                .setCommits(Map.of("abc123", CommitStatus.builder().build()))
+                .build();
+
+        // Configure mock to return our success response
+        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse);
+
+        // Execute the validation handler
+        helper.handleGithubWebhookValidation(request, vr, null);
+
+        // Verify that no comment was added since there were no errors
+        verify(ghApi, Mockito.never())
+                .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyInt(),
+                        Mockito.any());
+    }
+
     /**
      * Creates a GitHub webhook request for testing purposes.
      * 
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index ddeac400..88d68f97 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -31,6 +31,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment;
 import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.jboss.resteasy.reactive.RestResponse;
@@ -97,12 +98,6 @@ public class MockGithubAPI implements GithubAPI {
         return Response.ok().build();
     }
 
-    @Override
-    public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber,
-            GithubPullRequestCommentRequest comment) {
-        return Response.ok().build();
-    }
-
     @Override
     public RestResponse<List<GithubApplicationInstallationData>> getInstallations(BaseAPIParameters params, String bearer) {
         throw new UnsupportedOperationException("Unimplemented method 'getInstallations'");
@@ -118,4 +113,16 @@ public class MockGithubAPI implements GithubAPI {
         throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'");
     }
 
+    @Override
+    public RestResponse<List<GithubPullRequestComment>> getComments(String bearer, String apiVersion, String organization, String repo,
+            int pullNumber, int perPage, int page) {
+        return RestResponse.ok();
+    }
+
+    @Override
+    public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber,
+            GithubPullRequestCommentRequest commentRequest) {
+        return Response.ok().build();
+    }
+
 }
-- 
GitLab


From bf4fa1afc921aeb39b99c8ab6ce2825b94c4e958 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Thu, 24 Apr 2025 16:16:09 +0200
Subject: [PATCH 09/14] docs: adding comment to new class

---
 .../git/eca/api/models/GithubPullRequestComment.java           | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
index fe0b5ff1..f3956f1c 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
@@ -14,6 +14,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.google.auto.value.AutoValue;
 
+/**
+ * Model response for repos/{org}/{repo}/pulls/{pullNumber}/comments
+ */
 @AutoValue
 @JsonDeserialize(builder = AutoValue_GithubPullRequestComment.Builder.class)
 public abstract class GithubPullRequestComment {
-- 
GitLab


From d8a8e7764a3bdae5b49e63b304bb3849bb40e2a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Mon, 28 Apr 2025 10:25:59 +0200
Subject: [PATCH 10/14] refactor: moving getAllPullRequestComments to
 GithubHelper

---
 .../git/eca/api/GithubAPI.java                | 32 ----------------
 .../git/eca/helper/GithubHelper.java          | 38 ++++++++++++++++++-
 2 files changed, 36 insertions(+), 34 deletions(-)

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index 418bc631..90c4552d 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -167,36 +167,4 @@ public interface GithubAPI {
     @Path("installation/repositories")
     public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer);
 
-    /**
-     * Recursively fetches all comments from a pull request by handling pagination manually.
-     *
-     * @param bearer GitHub bearer token
-     * @param apiVersion GitHub API version
-     * @param org organization name
-     * @param repo repository name
-     * @param prNumber pull request number
-     * @return List of all comments from the pull request
-     */
-    default List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo,
-            int prNumber) {
-        List<GithubPullRequestComment> allComments = new ArrayList<>();
-        int page = 1;
-        int perPage = 100; // GitHub's maximum items per page
-
-        // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've
-        // reached the end
-        while (true) {
-            RestResponse<List<GithubPullRequestComment>> response = getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
-
-            List<GithubPullRequestComment> comments = response.getEntity();
-            if (Objects.isNull(comments) || comments.isEmpty()) {
-                break;
-            }
-
-            allComments.addAll(comments);
-            page++;
-        }
-
-        return allComments;
-    }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 073032ed..66dc3dc7 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -57,6 +57,7 @@ import org.eclipsefoundation.persistence.model.RDBMSQuery;
 import org.eclipsefoundation.persistence.service.FilterService;
 import org.eclipsefoundation.utils.helper.DateTimeHelper;
 import org.eclipsefoundation.utils.helper.TransformationHelper;
+import org.jboss.resteasy.reactive.RestResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import io.quarkus.qute.Location;
@@ -481,6 +482,40 @@ public class GithubHelper {
                                 .build());
     }
 
+
+    /**
+     * Recursively fetches all comments from a pull request by handling pagination manually.
+     *
+     * @param bearer GitHub bearer token
+     * @param apiVersion GitHub API version
+     * @param org organization name
+     * @param repo repository name
+     * @param prNumber pull request number
+     * @return List of all comments from the pull request
+     */
+    private List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo,
+            int prNumber) {
+        List<GithubPullRequestComment> allComments = new ArrayList<>();
+        int page = 1;
+        int perPage = 100; // GitHub's maximum items per page
+
+        // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've
+        // reached the end
+        while (true) {
+            RestResponse<List<GithubPullRequestComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
+
+            List<GithubPullRequestComment> comments = response.getEntity();
+            if (Objects.isNull(comments) || comments.isEmpty()) {
+                break;
+            }
+
+            allComments.addAll(comments);
+            page++;
+        }
+
+        return allComments;
+    }
+
     /**
      * This method posts a comment to the pull request detailing the validation errors and mentioning the usernames that need to take
      * action.
@@ -503,8 +538,7 @@ public class GithubHelper {
         Integer pullRequestNumber = request.getPullRequest().getNumber();
 
         // Get existing comments using pagination
-        List<GithubPullRequestComment> comments = ghApi
-                .getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber);
+        List<GithubPullRequestComment> comments = getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber);
 
         Set<String> nonMentionedUsers = usernames
                 .stream()
-- 
GitLab


From f1be4a98fe9647aa8d274c3ed2fd720e8670f259 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Mon, 19 May 2025 16:16:34 +0200
Subject: [PATCH 11/14] feat: using github issues API to send messages

---
 config/application/secret.properties.sample   |   5 +-
 .../git/eca/api/GithubAPI.java                |  35 ++--
 ...stComment.java => GithubIssueComment.java} |  30 +--
 .../api/models/GithubIssueCommentRequest.java |  60 ++++++
 .../git/eca/config/GithubBotConfig.java       |  37 ++++
 .../git/eca/helper/GithubHelper.java          |  85 ++++----
 .../git/eca/helper/GithubHelperTest.java      | 182 ++++++++++++------
 .../git/eca/test/api/MockGithubAPI.java       |  56 +++++-
 src/test/resources/application.properties     |   3 +
 9 files changed, 359 insertions(+), 134 deletions(-)
 rename src/main/java/org/eclipsefoundation/git/eca/api/models/{GithubPullRequestComment.java => GithubIssueComment.java} (63%)
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java

diff --git a/config/application/secret.properties.sample b/config/application/secret.properties.sample
index 99f4d517..47d46680 100644
--- a/config/application/secret.properties.sample
+++ b/config/application/secret.properties.sample
@@ -16,4 +16,7 @@ eclipse.gitlab.access-token=
 
 ## Used to send mail through the EclipseFdn smtp connection
 quarkus.mailer.password=YOURGENERATEDAPPLICATIONPASSWORD
-quarkus.mailer.username=YOUREMAIL@gmail.com
\ No newline at end of file
+quarkus.mailer.username=YOUREMAIL@gmail.com
+
+## github app bot id (this would be used to identify bot's comments)
+eclipse.github.bot.id=s0m3ID
\ No newline at end of file
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index 90c4552d..a03a0048 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -11,9 +11,7 @@
  */
 package org.eclipsefoundation.git.eca.api;
 
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Objects;
 
 import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
 import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters;
@@ -21,8 +19,8 @@ import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationData;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueComment;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.jboss.resteasy.reactive.RestResponse;
 
@@ -30,7 +28,6 @@ import jakarta.ws.rs.BeanParam;
 import jakarta.ws.rs.GET;
 import jakarta.ws.rs.HeaderParam;
 import jakarta.ws.rs.POST;
-import jakarta.ws.rs.PATCH;
 import jakarta.ws.rs.Path;
 import jakarta.ws.rs.PathParam;
 import jakarta.ws.rs.Produces;
@@ -65,7 +62,7 @@ public interface GithubAPI {
 
     /**
      * Retrieves a list of commits related to the given pull request.
-     * 
+     *
      * @param bearer authorization header value, access token for application with access to repo
      * @param apiVersion the version of the GH API to target when making the request
      * @param repo the repo name that is being targeted
@@ -80,7 +77,7 @@ public interface GithubAPI {
 
     /**
      * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha.
-     * 
+     *
      * @param bearer authorization header value, access token for application with access to repo
      * @param apiVersion the version of the GH API to target when making the request
      * @param organization the organization that owns the targeted repo
@@ -96,39 +93,39 @@ public interface GithubAPI {
             @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate);
 
     /**
-     * Adds a review comment to a pull request.
+     * Adds a comment to a pull request using the issues API.
      *
      * @param bearer authorization header value, access token for application with access to repo
      * @param apiVersion the version of the GH API to target when making the request
      * @param organization the organization that owns the targeted repo
      * @param repo the repo name that is being targeted
-     * @param pullNumber the number of the pull request to which the comment will be added.
-     * @param commentRequest the review comment request containing the comment details
+     * @param issueNumber the number of the issue/PR to which the comment will be added.
+     * @param commentRequest the comment request containing the comment message
      * @return response indicating the result of the operation.
      */
     @POST
-    @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
+    @Path("repos/{org}/{repo}/issues/{issueNumber}/comments")
     public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
-            @PathParam("pullNumber") int pullNumber, GithubPullRequestCommentRequest commentRequest);
+            @PathParam("issueNumber") int issueNumber, GithubIssueCommentRequest commentRequest);
 
     /**
-     * Lists comments on a pull request.
+     * Lists comments on an issue/pull request.
      *
      * @param bearer authorization header value, access token for application with access to repo
      * @param apiVersion the version of the GH API to target when making the request
      * @param organization the organization that owns the targeted repo
      * @param repo the repo name that is being targeted
-     * @param pullNumber the number of the pull request
+     * @param issueNumber the number of the issue/PR
      * @param perPage number of items to return per page, max 100
      * @param page page number to return
-     * @return list of comments on the pull request
+     * @return list of comments on the issue/pull request
      */
     @GET
-    @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments")
-    public RestResponse<List<GithubPullRequestComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
+    @Path("repos/{org}/{repo}/issues/{issueNumber}/comments")
+    public RestResponse<List<GithubIssueComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo,
-            @PathParam("pullNumber") int pullNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page);
+            @PathParam("issueNumber") int issueNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page);
 
     /**
      * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given
@@ -158,7 +155,7 @@ public interface GithubAPI {
 
     /**
      * Returns a list of repositories for the given installation.
-     * 
+     *
      * @param params the general params for requests, including pagination
      * @param bearer JWT bearer token for the target installation
      * @return list of repositories for the installation as a response for pagination
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java
similarity index 63%
rename from src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
rename to src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java
index f3956f1c..a2c00ffe 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java
@@ -15,42 +15,48 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.google.auto.value.AutoValue;
 
 /**
- * Model response for repos/{org}/{repo}/pulls/{pullNumber}/comments
+ * Model response for repos/{org}/{repo}/issues/{issueNumber}/comments
  */
 @AutoValue
-@JsonDeserialize(builder = AutoValue_GithubPullRequestComment.Builder.class)
-public abstract class GithubPullRequestComment {
+@JsonDeserialize(builder = AutoValue_GithubIssueComment.Builder.class)
+public abstract class GithubIssueComment {
     public abstract String getBody();
-    public abstract int getId();
+
+    public abstract Long getId();
+
     public abstract GithubUser getUser();
 
     @AutoValue
-    @JsonDeserialize(builder = AutoValue_GithubPullRequestComment_GithubUser.Builder.class)
+    @JsonDeserialize(builder = AutoValue_GithubIssueComment_GithubUser.Builder.class)
     public abstract static class GithubUser {
-        public abstract int getId();
-        
+        public abstract Long getId();
+
         public static Builder builder() {
-            return new AutoValue_GithubPullRequestComment_GithubUser.Builder();
+            return new AutoValue_GithubIssueComment_GithubUser.Builder();
         }
 
         @AutoValue.Builder
         @JsonPOJOBuilder(withPrefix = "set")
         public abstract static class Builder {
-            public abstract Builder setId(int id);
+            public abstract Builder setId(Long id);
+
             public abstract GithubUser build();
         }
     }
 
     public static Builder builder() {
-        return new AutoValue_GithubPullRequestComment.Builder();
+        return new AutoValue_GithubIssueComment.Builder();
     }
 
     @AutoValue.Builder
     @JsonPOJOBuilder(withPrefix = "set")
     public abstract static class Builder {
         public abstract Builder setBody(String body);
-        public abstract Builder setId(int id);
+
+        public abstract Builder setId(Long id);
+
         public abstract Builder setUser(GithubUser user);
-        public abstract GithubPullRequestComment build();
+
+        public abstract GithubIssueComment build();
     }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java
new file mode 100644
index 00000000..35b2adff
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java
@@ -0,0 +1,60 @@
+/**
+ * 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.api.models;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+/**
+ * Model for creating a comment on a GitHub issue or pull request.
+ * Uses the issues API endpoint which works for both issues and PRs.
+ */
+@AutoValue
+@JsonDeserialize(builder = AutoValue_GithubIssueCommentRequest.Builder.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public abstract class GithubIssueCommentRequest {
+    /**
+     * Gets the comment body text.
+     * 
+     * @return the comment body text
+     */
+    public abstract String getBody();
+
+    /**
+     * Creates a new builder for this class.
+     * 
+     * @return a new builder instance
+     */
+    public static Builder builder() {
+        return new AutoValue_GithubIssueCommentRequest.Builder();
+    }
+
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+        /**
+         * Sets the comment body text.
+         * 
+         * @param body the comment text to set
+         * @return this builder instance
+         */
+        public abstract Builder setBody(String body);
+
+        /**
+         * Builds a new GithubIssueCommentRequest instance.
+         * 
+         * @return the built instance
+         */
+        public abstract GithubIssueCommentRequest build();
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java
new file mode 100644
index 00000000..65a3a3d3
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java
@@ -0,0 +1,37 @@
+/**
+ * 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.config;
+
+import java.util.Optional;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+/**
+ * The intention of this class is to provide an static bot ID, given that this app only supports one installation, this way we can avoid
+ * having to retrieve this information from the API on every start, or storing it in the DB.
+ */
+@ApplicationScoped
+public class GithubBotConfig {
+
+    @ConfigProperty(name = "eclipse.github.bot.id")
+    Optional<Integer> botId;
+
+
+    /**
+     * Gets the GitHub bot ID.
+     *
+     * @return the bot ID as an Optional Integer
+     */
+    public Optional<Integer> getBotId() {
+        return botId;
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 66dc3dc7..2d5efc78 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -2,7 +2,7 @@
  * Copyright (c) 2023 Eclipse Foundation
  *
  * This program and the accompanying materials are made
- * available under the terms of the Eclipse Public License 2.0 
+ * available under the terms of the Eclipse Public License 2.0
  * which is available at https://www.eclipse.org/legal/epl-2.0/
  *
  * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
@@ -23,6 +23,7 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
+
 import org.apache.commons.lang3.StringUtils;
 import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.eclipse.microprofile.rest.client.inject.RestClient;
@@ -32,10 +33,11 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueComment;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
+import org.eclipsefoundation.git.eca.config.GithubBotConfig;
 import org.eclipsefoundation.git.eca.config.WebhooksConfig;
 import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
 import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation;
@@ -60,6 +62,7 @@ import org.eclipsefoundation.utils.helper.TransformationHelper;
 import org.jboss.resteasy.reactive.RestResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+
 import io.quarkus.qute.Location;
 import io.quarkus.qute.Template;
 import jakarta.enterprise.context.ApplicationScoped;
@@ -84,6 +87,9 @@ public class GithubHelper {
     @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
     String apiVersion;
 
+    @Inject
+    GithubBotConfig botConfig;
+
     @Inject
     WebhooksConfig webhooksConfig;
 
@@ -114,7 +120,7 @@ public class GithubHelper {
     /**
      * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and
      * validate the data.
-     * 
+     *
      * @param wrapper the wrapper for the current request
      * @param org the name of the GH organization
      * @param repoName the slug of the repo that has the PR to be validated
@@ -176,7 +182,7 @@ public class GithubHelper {
     /**
      * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the
      * installation ID for access/authorization.
-     * 
+     *
      * @param installationId the ECA app installation ID for the organization
      * @param repositoryFullName the full name of the repository where the PR resides
      * @param pullRequestNumber the pull request number that is being validated
@@ -234,7 +240,7 @@ public class GithubHelper {
     /**
      * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including
      * the existing ECA check, we don't need to check the commits here.
-     * 
+     *
      * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit
      * status of the resources
      */
@@ -258,7 +264,7 @@ public class GithubHelper {
     /**
      * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more
      * info on the states of the validation for more information
-     * 
+     *
      * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit
      * status of the resources
      * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic.
@@ -309,7 +315,7 @@ public class GithubHelper {
 
     /**
      * Searches for a commit in the validation request based on its hash value.
-     * 
+     *
      * @param vr The validation request containing the list of commits to search
      * @param hash The hash value to search for
      * @return An Optional containing the matching Commit if found, or empty if not found
@@ -321,7 +327,7 @@ public class GithubHelper {
     /**
      * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing
      * requests as well.
-     * 
+     *
      * @param installationId the installation ID for the ECA app in the given repository
      * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
      * @param pr the pull request targeted by the validation request.
@@ -336,7 +342,7 @@ public class GithubHelper {
 
     /**
      * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it.
-     * 
+     *
      * @param tracking the optional tracking entry for the current request
      * @param request the pull request that is being validated
      * @param installationId the ECA app installation ID for the current request
@@ -382,7 +388,7 @@ public class GithubHelper {
 
     /**
      * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number.
-     * 
+     *
      * @param installationId the installation ID for the ECA app in the given repository
      * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
      * @param pullRequestNumber the pull request number that is being processed currently
@@ -403,7 +409,7 @@ public class GithubHelper {
      * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing
      * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before
      * the starting time.
-     * 
+     *
      * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not
      * lead to a loss of data/integrity.
      */
@@ -444,7 +450,7 @@ public class GithubHelper {
 
     /**
      * Simple helper method so we don't have to repeat static strings in multiple places.
-     * 
+     *
      * @param fullRepoName the full repo name including org for the target PR.
      * @return the full repo URL on Github for the request
      */
@@ -454,7 +460,7 @@ public class GithubHelper {
 
     /**
      * Sends off a POST to update the commit status given a context for the current PR.
-     * 
+     *
      * @param request the current webhook update request
      * @param state the state to set the status to
      * @param fingerprint the internal unique string for the set of commits being processed
@@ -482,7 +488,6 @@ public class GithubHelper {
                                 .build());
     }
 
-
     /**
      * Recursively fetches all comments from a pull request by handling pagination manually.
      *
@@ -493,23 +498,25 @@ public class GithubHelper {
      * @param prNumber pull request number
      * @return List of all comments from the pull request
      */
-    private List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo,
-            int prNumber) {
-        List<GithubPullRequestComment> allComments = new ArrayList<>();
-        int page = 1;
+    private List<GithubIssueComment> getAllPullRequestCommentsByUserId(String bearer, String apiVersion, String org, String repo,
+            int prNumber, int userId) {
         int perPage = 100; // GitHub's maximum items per page
+        int page = 1; // Start from the first page
+        List<GithubIssueComment> allComments = new ArrayList<>();
 
         // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've
         // reached the end
         while (true) {
-            RestResponse<List<GithubPullRequestComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
+            RestResponse<List<GithubIssueComment>> response = ghApi
+                    .getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
 
-            List<GithubPullRequestComment> comments = response.getEntity();
+            List<GithubIssueComment> comments = response.getEntity();
             if (Objects.isNull(comments) || comments.isEmpty()) {
                 break;
             }
 
-            allComments.addAll(comments);
+            // We only want the comments made by the bot user
+            allComments.addAll(comments.stream().filter(comment -> comment.getUser().getId() == userId).collect(Collectors.toList()));
             page++;
         }
 
@@ -537,15 +544,21 @@ public class GithubHelper {
         String repositoryName = request.getRepository().getName();
         Integer pullRequestNumber = request.getPullRequest().getNumber();
 
-        // Get existing comments using pagination
-        List<GithubPullRequestComment> comments = getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber);
+        Set<String> nonMentionedUsers = Set.copyOf(usernames);
 
-        Set<String> nonMentionedUsers = usernames
-                .stream()
-                .filter(username -> comments
-                        .stream()
-                        .noneMatch(comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username))))
-                .collect(Collectors.toSet());
+        if (botConfig.getBotId().isPresent()) {
+            // Get existing comments using pagination
+            List<GithubIssueComment> comments = getAllPullRequestCommentsByUserId(ghBearerString, apiVersion, login, repositoryName,
+                    pullRequestNumber, botConfig.getBotId().get());
+
+            nonMentionedUsers = usernames
+                    .stream()
+                    .filter(username -> comments
+                            .stream()
+                            .noneMatch(
+                                    comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username))))
+                    .collect(Collectors.toSet());
+        }
 
         // If all the users have already been mentioned, skip commenting
         if (nonMentionedUsers.isEmpty()) {
@@ -553,11 +566,9 @@ public class GithubHelper {
             return;
         }
 
-        GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest
+        GithubIssueCommentRequest comment = GithubIssueCommentRequest
                 .builder()
-                .setBody(failureMessage.data("reasons", errors).data("usernames", usernames).render())
-                .setCommitId(request.getPullRequest().getHead().getSha())
-                .setPath(".")
+                .setBody(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render())
                 .build();
 
         LOGGER
@@ -573,7 +584,7 @@ public class GithubHelper {
 
     /**
      * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved.
-     * 
+     *
      * @param supplier the method with potentially nullable values
      * @return the value if it can be found, or null
      */
@@ -588,7 +599,7 @@ public class GithubHelper {
 
     /**
      * Validates required fields for processing requests.
-     * 
+     *
      * @param installationId the installation ID for the ECA app in the given repository
      * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty
      * @param pullRequestNumber the pull request number that is being processed currently
@@ -618,7 +629,7 @@ public class GithubHelper {
     /**
      * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching.
      * Checks database for existing record, and returns record with a touched date for existing entries.
-     * 
+     *
      * @param ghInstallation raw Github installation record for current application
      * @return the new or updated installation record to be persisted to the database.
      */
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 79da0acc..91e08bd9 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -2,7 +2,7 @@
  * Copyright (c) 2025 Eclipse Foundation
  *
  * This program and the accompanying materials are made
- * available under the terms of the Eclipse Public License 2.0 
+ * 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
@@ -10,7 +10,8 @@
 
 package org.eclipsefoundation.git.eca.helper;
 
-import static org.mockito.Mockito.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.when;
 
 import java.net.URI;
@@ -22,7 +23,7 @@ import java.util.Map;
 
 import org.eclipse.microprofile.rest.client.inject.RestClient;
 import org.eclipsefoundation.git.eca.api.GithubAPI;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueComment;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.model.Commit;
 import org.eclipsefoundation.git.eca.model.CommitStatus;
@@ -32,11 +33,12 @@ import org.eclipsefoundation.git.eca.model.ValidationResponse;
 import org.eclipsefoundation.git.eca.namespace.APIStatusCode;
 import org.eclipsefoundation.git.eca.namespace.ProviderType;
 import org.eclipsefoundation.git.eca.service.ValidationService;
+import org.eclipsefoundation.git.eca.test.api.MockGithubAPI;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 import io.quarkus.test.InjectMock;
 import io.quarkus.test.junit.QuarkusTest;
-import io.quarkus.test.junit.mockito.InjectSpy;
 import jakarta.inject.Inject;
 
 @QuarkusTest
@@ -46,14 +48,31 @@ class GithubHelperTest {
     GithubHelper helper;
 
     @RestClient
-    @InjectSpy
+    @Inject
     GithubAPI ghApi;
 
     @InjectMock
     ValidationService validationService;
 
+    /**
+     * Clean up the test environment before each test. This is important for Quarkus tests as beans maintain state between tests.
+     */
+    @BeforeEach
+    void setUp() {
+        // Clean up the MockGithubAPI state if it's our mock implementation
+        if (ghApi instanceof MockGithubAPI mockGithubApi) {
+            mockGithubApi.cleanState();
+        }
+
+        // Reset mock interactions and stubbing
+        Mockito.reset(validationService);
+    }
+
+    /**
+     * Test validation when multiple commits have ECA validation errors. Verifies that all users with invalid commits are properly notified.
+     */
     @Test
-    void testHandleGithubWebhookValidation_WithErrors() {
+    void testHandleGithubWebhookValidation_MultipleCommitErrors() {
         // Set up test data for the PR
         String orgName = "test-org";
         String repoName = "test-repo";
@@ -62,17 +81,9 @@ class GithubHelperTest {
         Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com");
         Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com");
 
-        // Create webhook request simulating a GitHub PR event
+        // Create webhook request and validation request
         GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
-        List<Commit> commits = List.of(testCommit1, testCommit2);
-
-        // Build a validation request as the service would receive it
-        ValidationRequest vr = ValidationRequest
-                .builder()
-                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
-                .setProvider(ProviderType.GITHUB)
-                .setCommits(commits)
-                .build();
+        ValidationRequest vr = createValidationRequest(orgName, repoName, List.of(testCommit1, testCommit2));
 
         // Create a mock validation response with multiple errors
         ValidationResponse mockResponse = createErrorValidationResponse(
@@ -84,37 +95,50 @@ class GithubHelperTest {
         // Execute the validation handler
         helper.handleGithubWebhookValidation(request, vr, null);
 
-        // Prepare the expected error comment that should be posted to GitHub
-        var expectedRequestBody = GithubPullRequestCommentRequest
-                .builder()
-                .setBody(
-                        """
-                                Hi @testUser @testUser2 — thank you for your contribution!
+        // Verify that the correct error comment was added to the PR
+        verifyErrorComment(orgName, repoName, prNumber, List.of("testUser", "testUser2"), List.of("Missing ECA for user", "Another error"));
+    }
 
-                                The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons:
+    /**
+     * Test validation when new commits are added to a PR where some users were previously notified. Verifies that only new users are
+     * notified of validation errors.
+     */
+    @Test
+    void testHandleGithubWebhookValidation_OnlyNewUsersNotified() {
+        // Set up test data
+        String orgName = "test-org";
+        String repoName = "test-repo";
+        int prNumber = 42;
 
-                                - Missing ECA for user
-                                - Another error
+        // First validation with initial commits
+        Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com");
+        GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
+        ValidationRequest initialVr = createValidationRequest(orgName, repoName, List.of(testCommit1));
 
-                                To resolve this, please:
+        // Mock initial validation response
+        ValidationResponse initialMockResponse = createErrorValidationResponse(Map.of(testCommit1, List.of("Missing ECA for user")));
+        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(initialMockResponse);
 
-                                1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca
-                                1. Ensure your GitHub username is linked to your Eclipse account
-                                1. Complete and submit the ECA form
+        // Execute initial validation
+        helper.handleGithubWebhookValidation(request, initialVr, null);
 
-                                Once done, push a new commit (or rebase) to re-trigger the ECA validation.
+        // Verify initial error comment
+        verifyErrorComment(orgName, repoName, prNumber, List.of("testUser"), List.of("Missing ECA for user"));
 
-                                If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk).
+        // Second validation with new commit
+        Commit testCommit3 = createTestCommit("ghi789", "testUser3", "test3@example.com");
+        ValidationRequest newVr = createValidationRequest(orgName, repoName, List.of(testCommit1, testCommit3));
 
-                                Thanks again for your contribution!""")
-                .setCommitId(request.getPullRequest().getHead().getSha())
-                .setPath(".")
-                .build();
+        // Mock new validation response
+        ValidationResponse newMockResponse = createErrorValidationResponse(
+                Map.of(testCommit1, List.of("Missing ECA for user"), testCommit3, List.of("Missing ECA for user", "Another error")));
+        when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(newMockResponse);
 
-        // Verify that the correct error comment was added to the PR
-        verify(ghApi)
-                .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber),
-                        Mockito.eq(expectedRequestBody));
+        // Execute new validation
+        helper.handleGithubWebhookValidation(request, newVr, null);
+
+        // Verify that only the new user is notified
+        verifyErrorComment(orgName, repoName, prNumber, List.of("testUser3"), List.of("Missing ECA for user", "Another error"));
     }
 
     @Test
@@ -128,15 +152,7 @@ class GithubHelperTest {
 
         // Create webhook request simulating a GitHub PR event
         GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123");
-        List<Commit> commits = List.of(testCommit);
-
-        // Build a validation request as the service would receive it
-        ValidationRequest vr = ValidationRequest
-                .builder()
-                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
-                .setProvider(ProviderType.GITHUB)
-                .setCommits(commits)
-                .build();
+        ValidationRequest vr = createValidationRequest(orgName, repoName, List.of(testCommit));
 
         // Create a mock successful validation response with no errors
         ValidationResponse mockResponse = ValidationResponse
@@ -152,15 +168,14 @@ class GithubHelperTest {
         // Execute the validation handler
         helper.handleGithubWebhookValidation(request, vr, null);
 
-        // Verify that no comment was added since there were no errors
-        verify(ghApi, Mockito.never())
-                .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyInt(),
-                        Mockito.any());
+        // Verify no comments were added
+        var comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity();
+        assertTrue(comments.isEmpty(), "No comments should be added when there are no errors");
     }
 
     /**
      * Creates a GitHub webhook request for testing purposes.
-     * 
+     *
      * @param installationId The installation ID
      * @param repoName The repository name
      * @param orgName The organization name
@@ -191,7 +206,7 @@ class GithubHelperTest {
 
     /**
      * Creates a test commit with specified user details.
-     * 
+     *
      * @param commitHash The commit hash
      * @param userName The user's name
      * @param userEmail The user's email
@@ -212,7 +227,7 @@ class GithubHelperTest {
 
     /**
      * Creates a validation response with an error status.
-     * 
+     *
      * @param commitHash The commit hash to associate the error with
      * @param errorMessage The error message
      * @return ValidationResponse instance
@@ -233,4 +248,61 @@ class GithubHelperTest {
                 .build();
     }
 
+    /**
+     * Creates a validation request for testing.
+     *
+     * @param orgName The organization name
+     * @param repoName The repository name
+     * @param commits The list of commits to validate
+     * @return ValidationRequest instance
+     */
+    private ValidationRequest createValidationRequest(String orgName, String repoName, List<Commit> commits) {
+        return ValidationRequest
+                .builder()
+                .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName))
+                .setProvider(ProviderType.GITHUB)
+                .setCommits(commits)
+                .build();
+    }
+
+    /**
+     * Verifies that an error comment was posted to GitHub with the expected content.
+     *
+     * @param orgName The organization name
+     * @param repoName The repository name
+     * @param prNumber The pull request number
+     * @param usernames List of GitHub usernames to be mentioned
+     * @param errors List of error messages
+     */
+    private void verifyErrorComment(String orgName, String repoName, int prNumber, List<String> usernames, List<String> errors) {
+        String mentions = usernames.stream().map(user -> "@" + user).collect(java.util.stream.Collectors.joining(" "));
+        String errorBullets = errors.stream().map(error -> "- " + error).collect(java.util.stream.Collectors.joining("\n"));
+
+        String expectedBody = String
+                .format("""
+                        Hi %s — thank you for your contribution!
+
+                        The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons:
+
+                        %s
+
+                        To resolve this, please:
+
+                        1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca
+                        1. Ensure your GitHub username is linked to your Eclipse account
+                        1. Complete and submit the ECA form
+
+                        Once done, push a new commit (or rebase) to re-trigger the ECA validation.
+
+                        If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk).
+
+                        Thanks again for your contribution!""",
+                        mentions, errorBullets);
+
+        // Get the comments and verify content
+        List<GithubIssueComment> comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity();
+        assertEquals(1, comments.stream().filter(comment -> comment.getBody().equals(expectedBody)).toList().size(),
+                "Expected one comment with the expected body");
+    }
+
 }
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index 88d68f97..6148aa25 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -2,7 +2,7 @@
  * Copyright (c) 2023 Eclipse Foundation.
  *
  * This program and the accompanying materials are made
- * available under the terms of the Eclipse Public License 2.0 
+ * available under the terms of the Eclipse Public License 2.0
  * which is available at https://www.eclipse.org/legal/epl-2.0/
  *
  * Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org>
@@ -12,15 +12,13 @@
 package org.eclipsefoundation.git.eca.test.api;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-import jakarta.enterprise.context.ApplicationScoped;
-import jakarta.ws.rs.core.Response;
-
 import org.eclipse.microprofile.rest.client.inject.RestClient;
 import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters;
 import org.eclipsefoundation.git.eca.api.GithubAPI;
@@ -31,12 +29,16 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment;
-import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueComment;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
+import org.eclipsefoundation.git.eca.config.GithubBotConfig;
 import org.jboss.resteasy.reactive.RestResponse;
 
 import io.quarkus.test.Mock;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.core.Response;
 
 @Mock
 @RestClient
@@ -45,10 +47,15 @@ public class MockGithubAPI implements GithubAPI {
 
     Map<String, Map<Integer, List<GithubCommit>>> commits;
     Map<String, Map<String, String>> commitStatuses;
+    Map<String, Map<Integer, List<GithubIssueComment>>> comments;
+
+    @Inject
+    GithubBotConfig githubBotConfig;
 
     public MockGithubAPI() {
         this.commitStatuses = new HashMap<>();
         this.commits = new HashMap<>();
+        this.comments = new HashMap<>();
         this.commits
                 .put("eclipsefdn/sample",
                         Map
@@ -114,15 +121,44 @@ public class MockGithubAPI implements GithubAPI {
     }
 
     @Override
-    public RestResponse<List<GithubPullRequestComment>> getComments(String bearer, String apiVersion, String organization, String repo,
+    public RestResponse<List<GithubIssueComment>> getComments(String bearer, String apiVersion, String organization, String repo,
             int pullNumber, int perPage, int page) {
-        return RestResponse.ok();
+
+        // to avoid loops when loading all comments
+        if (page > 1) {
+            return RestResponse.ok(Collections.emptyList());
+        }
+
+        String repoFullName = organization + '/' + repo;
+        Map<Integer, List<GithubIssueComment>> repoMap = comments.getOrDefault(repoFullName, Collections.emptyMap());
+        List<GithubIssueComment> repoComments = repoMap.getOrDefault(pullNumber, Collections.emptyList());
+        return RestResponse.ok(repoComments);
     }
 
     @Override
-    public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber,
-            GithubPullRequestCommentRequest commentRequest) {
+    public Response addComment(String bearer, String apiVersion, String organization, String repo, int issueNumber,
+            GithubIssueCommentRequest commentRequest) {
+        String repoFullName = organization + '/' + repo;
+        GithubIssueComment comment = GithubIssueComment
+                .builder()
+                .setId(0)
+                .setBody(commentRequest.getBody())
+                .setUser(GithubIssueComment.GithubUser.builder().setId(githubBotConfig.getBotId().orElse(0)).build())
+                .build();
+
+        comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment);
+
         return Response.ok().build();
     }
 
+    /**
+     * Cleans up the internal state of the mock.
+     * This method should be called before each test to ensure a clean state.
+     */
+    public void cleanState() {
+        this.commits = new HashMap<>();
+        this.commitStatuses = new HashMap<>();
+        this.comments = new HashMap<>();
+    }
+    
 }
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index bd6e17d8..4a6a4075 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -44,3 +44,6 @@ eclipse.gitlab.access-token=token_val
 
 ## Disable private project scan in test mode
 eclipse.scheduled.private-project.enabled=false
+
+## GitHub App bot ID
+eclipse.github.bot.id=111111
\ No newline at end of file
-- 
GitLab


From 8b051c71ed1da9ee806df2bb014a67bec7e05a05 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Mon, 19 May 2025 16:26:10 +0200
Subject: [PATCH 12/14] chore: removing unused class

---
 .../GithubPullRequestCommentRequest.java      | 65 -------------------
 1 file changed, 65 deletions(-)
 delete mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java

diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
deleted file mode 100644
index 2690fe80..00000000
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * 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.api.models;
-
-import jakarta.annotation.Nullable;
-
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
-import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
-import com.google.auto.value.AutoValue;
-
-/**
- * Model for creating a review comment on a pull request.
- */
-@AutoValue
-@JsonDeserialize(builder = AutoValue_GithubPullRequestCommentRequest.Builder.class)
-public abstract class GithubPullRequestCommentRequest {
-    public abstract String getBody();
-
-    public abstract String getCommitId();
-
-    public abstract String getPath();
-
-    @Nullable
-    public abstract Integer getLine();
-
-    @Nullable
-    public abstract String getSide();
-
-    @Nullable
-    public abstract Integer getStartLine();
-
-    @Nullable
-    public abstract String getStartSide();
-
-    public static Builder builder() {
-        return new AutoValue_GithubPullRequestCommentRequest.Builder();
-    }
-
-    @AutoValue.Builder
-    @JsonPOJOBuilder(withPrefix = "set")
-    public abstract static class Builder {
-        public abstract Builder setBody(String body);
-
-        public abstract Builder setCommitId(String commitId);
-
-        public abstract Builder setPath(String path);
-
-        public abstract Builder setLine(@Nullable Integer line);
-
-        public abstract Builder setSide(@Nullable String side);
-
-        public abstract Builder setStartLine(@Nullable Integer startLine);
-
-        public abstract Builder setStartSide(@Nullable String startSide);
-
-        public abstract GithubPullRequestCommentRequest build();
-    }
-}
-- 
GitLab


From 23e3f8b4a0d22ef3722971e416d9b41a34c35daa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Mon, 19 May 2025 16:30:08 +0200
Subject: [PATCH 13/14] fix: updating gh IssueComment usage

---
 .../org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index 6148aa25..db5b52ef 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -141,9 +141,9 @@ public class MockGithubAPI implements GithubAPI {
         String repoFullName = organization + '/' + repo;
         GithubIssueComment comment = GithubIssueComment
                 .builder()
-                .setId(0)
+                .setId(Long.valueOf(0))
                 .setBody(commentRequest.getBody())
-                .setUser(GithubIssueComment.GithubUser.builder().setId(githubBotConfig.getBotId().orElse(0)).build())
+                .setUser(GithubIssueComment.GithubUser.builder().setId(Long.valueOf(githubBotConfig.getBotId().orElse(0))).build())
                 .build();
 
         comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment);
-- 
GitLab


From f9a7456222733722ef1438b40161f969ae2271c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org>
Date: Wed, 21 May 2025 16:13:41 +0200
Subject: [PATCH 14/14] feat: migrating some Autovalue classes to records +
 small changes

---
 pom.xml                                       | 34 +++++++++--
 .../eca/api/models/GithubIssueComment.java    | 61 +++++--------------
 .../api/models/GithubIssueCommentRequest.java | 44 +------------
 .../git/eca/config/GithubBotConfig.java       | 23 ++-----
 .../git/eca/helper/GithubHelper.java          | 43 +++++++------
 .../git/eca/helper/GithubHelperTest.java      |  2 +-
 .../git/eca/test/api/MockGithubAPI.java       | 11 ++--
 7 files changed, 83 insertions(+), 135 deletions(-)

diff --git a/pom.xml b/pom.xml
index ec754369..b712261f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,5 +1,18 @@
 <?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">
+<!--
+ Copyright (c) 2020
+ 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
+-->
+
+<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>
@@ -15,7 +28,7 @@
         <quarkus.platform.version>3.15.4</quarkus.platform.version>
         <surefire-plugin.version>3.3.1</surefire-plugin.version>
         <maven.compiler.parameters>true</maven.compiler.parameters>
-        <eclipse-api-version>1.2.3</eclipse-api-version>
+        <eclipse-api-version>1.2.5</eclipse-api-version>
         <auto-value.version>1.10.4</auto-value.version>
         <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
         <sonar.sources>src/main</sonar.sources>
@@ -75,8 +88,8 @@
             <artifactId>quarkus-smallrye-jwt</artifactId>
         </dependency>
         <dependency>
-          <groupId>io.quarkus</groupId>
-          <artifactId>quarkus-mailer</artifactId>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-mailer</artifactId>
         </dependency>
 
         <!-- Annotation preprocessors - reduce all of the boiler plate -->
@@ -97,6 +110,12 @@
             <version>${org.mapstruct.version}</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>io.soabase.record-builder</groupId>
+            <artifactId>record-builder-processor</artifactId>
+            <version>45</version>
+            <scope>provided</scope>
+        </dependency>
 
         <!-- Test requirements -->
         <dependency>
@@ -168,6 +187,11 @@
                             <artifactId>mapstruct-processor</artifactId>
                             <version>${org.mapstruct.version}</version>
                         </path>
+                        <path>
+                            <groupId>io.soabase.record-builder</groupId>
+                            <artifactId>record-builder-processor</artifactId>
+                            <version>45</version>
+                        </path>
                     </annotationProcessorPaths>
                 </configuration>
             </plugin>
@@ -184,4 +208,4 @@
             </plugin>
         </plugins>
     </build>
-</project>
+</project>
\ No newline at end of file
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java
index a2c00ffe..91a798a7 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java
@@ -10,53 +10,24 @@
 
 package org.eclipsefoundation.git.eca.api.models;
 
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
-import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
-import com.google.auto.value.AutoValue;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+import io.soabase.recordbuilder.core.RecordBuilder;
 
 /**
  * Model response for repos/{org}/{repo}/issues/{issueNumber}/comments
  */
-@AutoValue
-@JsonDeserialize(builder = AutoValue_GithubIssueComment.Builder.class)
-public abstract class GithubIssueComment {
-    public abstract String getBody();
-
-    public abstract Long getId();
-
-    public abstract GithubUser getUser();
-
-    @AutoValue
-    @JsonDeserialize(builder = AutoValue_GithubIssueComment_GithubUser.Builder.class)
-    public abstract static class GithubUser {
-        public abstract Long getId();
-
-        public static Builder builder() {
-            return new AutoValue_GithubIssueComment_GithubUser.Builder();
-        }
-
-        @AutoValue.Builder
-        @JsonPOJOBuilder(withPrefix = "set")
-        public abstract static class Builder {
-            public abstract Builder setId(Long id);
-
-            public abstract GithubUser build();
-        }
-    }
-
-    public static Builder builder() {
-        return new AutoValue_GithubIssueComment.Builder();
-    }
-
-    @AutoValue.Builder
-    @JsonPOJOBuilder(withPrefix = "set")
-    public abstract static class Builder {
-        public abstract Builder setBody(String body);
-
-        public abstract Builder setId(Long id);
-
-        public abstract Builder setUser(GithubUser user);
-
-        public abstract GithubIssueComment build();
-    }
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+@RecordBuilder
+public record GithubIssueComment(
+    String body,
+    Long id,
+    GithubUser user
+) {
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    @RecordBuilder
+    public static record GithubUser(
+        Long id
+    ) {}
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java
index 35b2adff..b6ec978d 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java
@@ -10,51 +10,13 @@
 package org.eclipsefoundation.git.eca.api.models;
 
 import com.fasterxml.jackson.databind.PropertyNamingStrategies;
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonNaming;
-import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
-import com.google.auto.value.AutoValue;
+import io.soabase.recordbuilder.core.RecordBuilder;
 
 /**
  * Model for creating a comment on a GitHub issue or pull request.
  * Uses the issues API endpoint which works for both issues and PRs.
  */
-@AutoValue
-@JsonDeserialize(builder = AutoValue_GithubIssueCommentRequest.Builder.class)
 @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
-public abstract class GithubIssueCommentRequest {
-    /**
-     * Gets the comment body text.
-     * 
-     * @return the comment body text
-     */
-    public abstract String getBody();
-
-    /**
-     * Creates a new builder for this class.
-     * 
-     * @return a new builder instance
-     */
-    public static Builder builder() {
-        return new AutoValue_GithubIssueCommentRequest.Builder();
-    }
-
-    @AutoValue.Builder
-    @JsonPOJOBuilder(withPrefix = "set")
-    public abstract static class Builder {
-        /**
-         * Sets the comment body text.
-         * 
-         * @param body the comment text to set
-         * @return this builder instance
-         */
-        public abstract Builder setBody(String body);
-
-        /**
-         * Builds a new GithubIssueCommentRequest instance.
-         * 
-         * @return the built instance
-         */
-        public abstract GithubIssueCommentRequest build();
-    }
-}
\ No newline at end of file
+@RecordBuilder
+public record GithubIssueCommentRequest(String body) {}
\ No newline at end of file
diff --git a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java
index 65a3a3d3..8d26a3e1 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java
@@ -9,29 +9,14 @@
  */
 package org.eclipsefoundation.git.eca.config;
 
+import io.smallrye.config.ConfigMapping;
 import java.util.Optional;
 
-import org.eclipse.microprofile.config.inject.ConfigProperty;
-
-import jakarta.enterprise.context.ApplicationScoped;
-
 /**
  * The intention of this class is to provide an static bot ID, given that this app only supports one installation, this way we can avoid
  * having to retrieve this information from the API on every start, or storing it in the DB.
  */
-@ApplicationScoped
-public class GithubBotConfig {
-
-    @ConfigProperty(name = "eclipse.github.bot.id")
-    Optional<Integer> botId;
-
-
-    /**
-     * Gets the GitHub bot ID.
-     *
-     * @return the bot ID as an Optional Integer
-     */
-    public Optional<Integer> getBotId() {
-        return botId;
-    }
+@ConfigMapping(prefix = "eclipse.github.bot")
+public interface GithubBotConfig {
+    Optional<Long> id();
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
index 2d5efc78..0f5e15a2 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java
@@ -35,6 +35,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubIssueComment;
 import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequestBuilder;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.config.GithubBotConfig;
@@ -153,7 +154,7 @@ public class GithubHelper {
         }
         LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo);
 
-        // retrieve the webhook tracking info, or generate an entry to track this PR
+        // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing.
         GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName,
                 prResponse.get());
         if (updatedTracking == null) {
@@ -472,9 +473,12 @@ public class GithubHelper {
             throw new IllegalStateException("Pull request should not be null when handling validation");
         }
 
-        LOGGER
-                .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
-                        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        if (LOGGER.isTraceEnabled()) {
+            LOGGER
+                    .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
+                            jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        }
+
         ghApi
                 .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
                         request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(),
@@ -499,24 +503,23 @@ public class GithubHelper {
      * @return List of all comments from the pull request
      */
     private List<GithubIssueComment> getAllPullRequestCommentsByUserId(String bearer, String apiVersion, String org, String repo,
-            int prNumber, int userId) {
+            int prNumber, Long userId) {
         int perPage = 100; // GitHub's maximum items per page
         int page = 1; // Start from the first page
         List<GithubIssueComment> allComments = new ArrayList<>();
 
         // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've
         // reached the end
-        while (true) {
-            RestResponse<List<GithubIssueComment>> response = ghApi
-                    .getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
+        while (page < 50) {
+            RestResponse<List<GithubIssueComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page);
 
             List<GithubIssueComment> comments = response.getEntity();
-            if (Objects.isNull(comments) || comments.isEmpty()) {
+            if (comments == null || comments.isEmpty()) {
                 break;
             }
 
             // We only want the comments made by the bot user
-            allComments.addAll(comments.stream().filter(comment -> comment.getUser().getId() == userId).collect(Collectors.toList()));
+            allComments.addAll(comments.stream().filter(comment -> comment.user().id() == userId).collect(Collectors.toList()));
             page++;
         }
 
@@ -546,17 +549,17 @@ public class GithubHelper {
 
         Set<String> nonMentionedUsers = Set.copyOf(usernames);
 
-        if (botConfig.getBotId().isPresent()) {
+        if (botConfig.id().isPresent()) {
             // Get existing comments using pagination
             List<GithubIssueComment> comments = getAllPullRequestCommentsByUserId(ghBearerString, apiVersion, login, repositoryName,
-                    pullRequestNumber, botConfig.getBotId().get());
+                    pullRequestNumber, botConfig.id().get());
 
             nonMentionedUsers = usernames
                     .stream()
                     .filter(username -> comments
                             .stream()
                             .noneMatch(
-                                    comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username))))
+                                    comment -> Objects.requireNonNullElse(comment.body(), "").contains(String.format("@%s", username))))
                     .collect(Collectors.toSet());
         }
 
@@ -566,18 +569,20 @@ public class GithubHelper {
             return;
         }
 
-        GithubIssueCommentRequest comment = GithubIssueCommentRequest
+        GithubIssueCommentRequest comment = GithubIssueCommentRequestBuilder 
                 .builder()
-                .setBody(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render())
+                .body(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render())
                 .build();
 
-        LOGGER
-                .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
-                        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        if (LOGGER.isTraceEnabled()) {
+            LOGGER
+                    .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
+                            jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
+        }
 
         LOGGER
                 .trace("Adding new comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login),
-                        TransformationHelper.formatLog(repositoryName), comment.getBody());
+                        TransformationHelper.formatLog(repositoryName), comment.body());
 
         ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment);
     }
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
index 91e08bd9..7a8d3dd9 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java
@@ -301,7 +301,7 @@ class GithubHelperTest {
 
         // Get the comments and verify content
         List<GithubIssueComment> comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity();
-        assertEquals(1, comments.stream().filter(comment -> comment.getBody().equals(expectedBody)).toList().size(),
+        assertEquals(1, comments.stream().filter(comment -> comment.body().equals(expectedBody)).toList().size(),
                 "Expected one comment with the expected body");
     }
 
diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
index db5b52ef..011d8755 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java
@@ -30,6 +30,8 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubIssueComment;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentBuilder;
+import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentGithubUserBuilder;
 import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.config.GithubBotConfig;
@@ -139,11 +141,10 @@ public class MockGithubAPI implements GithubAPI {
     public Response addComment(String bearer, String apiVersion, String organization, String repo, int issueNumber,
             GithubIssueCommentRequest commentRequest) {
         String repoFullName = organization + '/' + repo;
-        GithubIssueComment comment = GithubIssueComment
-                .builder()
-                .setId(Long.valueOf(0))
-                .setBody(commentRequest.getBody())
-                .setUser(GithubIssueComment.GithubUser.builder().setId(Long.valueOf(githubBotConfig.getBotId().orElse(0))).build())
+        GithubIssueComment comment = GithubIssueCommentBuilder.builder()
+                .id(0L)
+                .body(commentRequest.body())
+                .user(GithubIssueCommentGithubUserBuilder.builder().id(githubBotConfig.id().orElse(0L)).build())
                 .build();
 
         comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment);
-- 
GitLab