diff --git a/config/mariadb/initdb.d/init.sql b/config/mariadb/initdb.d/init.sql
index 45938283d5c8f57751c83219404ca36206f9670e..002b10e748dc44ccb6c5c36e278dd15354095f4c 100644
--- a/config/mariadb/initdb.d/init.sql
+++ b/config/mariadb/initdb.d/init.sql
@@ -49,5 +49,7 @@ CREATE TABLE GithubWebhookTracking (
   repositoryFullName varchar(127) NOT NULL,
   pullRequestNumber int NOT NULL,
   lastUpdated datetime DEFAULT NULL,
+  needsRevalidation tinyint(1) DEFAULT 0,
+  manualRevalidationCount int DEFAULT 0,
   PRIMARY KEY (id)
 );
\ No newline at end of file
diff --git a/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java b/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
index 72cb98ceac7b3941ca8df976b2ad6262fc067f7d..e40f3f3395da07b0cbc3186c4abf207a035c26c5 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
@@ -30,6 +30,7 @@ import org.eclipsefoundation.persistence.dto.filter.DtoFilter;
 import org.eclipsefoundation.persistence.model.DtoTable;
 import org.eclipsefoundation.persistence.model.ParameterizedSQLStatement;
 import org.eclipsefoundation.persistence.model.ParameterizedSQLStatementBuilder;
+import org.eclipsefoundation.persistence.model.SortableField;
 
 /**
  * Github Webhook request tracking info. Tracking is required to trigger revalidation through the Github API from this
@@ -51,7 +52,10 @@ public class GithubWebhookTracking extends BareNode {
     private String headSha;
     private Integer pullRequestNumber;
     private String lastKnownState;
+    @SortableField
     private ZonedDateTime lastUpdated;
+    private boolean needsRevalidation;
+    private Integer manualRevalidationCount;
 
     @Override
     public Long getId() {
@@ -149,12 +153,41 @@ public class GithubWebhookTracking extends BareNode {
         this.lastUpdated = lastUpdated;
     }
 
+    /**
+     * @return the needsRevalidation
+     */
+    public boolean isNeedsRevalidation() {
+        return needsRevalidation;
+    }
+
+    /**
+     * @param needsRevalidation the needsRevalidation to set
+     */
+    public void setNeedsRevalidation(boolean needsRevalidation) {
+        this.needsRevalidation = needsRevalidation;
+    }
+
+    /**
+     * @return the manualRevalidationCount
+     */
+    public Integer getManualRevalidationCount() {
+        return manualRevalidationCount;
+    }
+
+    /**
+     * @param manualRevalidationCount the manualRevalidationCount to set
+     */
+    public void setManualRevalidationCount(Integer manualRevalidationCount) {
+        this.manualRevalidationCount = manualRevalidationCount;
+    }
+
     @Override
     public int hashCode() {
         final int prime = 31;
         int result = super.hashCode();
-        result = prime * result
-                + Objects.hash(headSha, id, installationId, lastKnownState, lastUpdated, pullRequestNumber, repositoryFullName);
+        result = prime * result + Objects
+                .hash(headSha, id, installationId, lastKnownState, lastUpdated, manualRevalidationCount, needsRevalidation,
+                        pullRequestNumber, repositoryFullName);
         return result;
     }
 
@@ -172,7 +205,8 @@ public class GithubWebhookTracking extends BareNode {
         GithubWebhookTracking other = (GithubWebhookTracking) obj;
         return Objects.equals(headSha, other.headSha) && Objects.equals(id, other.id)
                 && Objects.equals(installationId, other.installationId) && Objects.equals(lastKnownState, other.lastKnownState)
-                && Objects.equals(lastUpdated, other.lastUpdated) && Objects.equals(pullRequestNumber, other.pullRequestNumber)
+                && Objects.equals(lastUpdated, other.lastUpdated) && Objects.equals(manualRevalidationCount, other.manualRevalidationCount)
+                && needsRevalidation == other.needsRevalidation && Objects.equals(pullRequestNumber, other.pullRequestNumber)
                 && Objects.equals(repositoryFullName, other.repositoryFullName);
     }
 
@@ -204,6 +238,10 @@ public class GithubWebhookTracking extends BareNode {
                         .addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".repositoryFullName = ?",
                                 new Object[] { repositoryFullName }));
             }
+            String needsRevalidation = params.getFirst(GitEcaParameterNames.NEEDS_REVALIDATION_RAW);
+            if (Boolean.TRUE.equals(Boolean.valueOf(needsRevalidation))) {
+                statement.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".needsRevalidation = TRUE", new Object[] {}));
+            }
 
             return statement;
         }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubValidationHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubValidationHelper.java
new file mode 100644
index 0000000000000000000000000000000000000000..9f4c34a02151c1f9186befefcf35812791247838
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubValidationHelper.java
@@ -0,0 +1,391 @@
+/**
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.helper;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import javax.enterprise.context.ApplicationScoped;
+import javax.inject.Inject;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.ServerErrorException;
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpStatus;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.eclipsefoundation.core.helper.DateTimeHelper;
+import org.eclipsefoundation.core.model.RequestWrapper;
+import org.eclipsefoundation.core.service.APIMiddleware;
+import org.eclipsefoundation.git.eca.api.GithubAPI;
+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.GithubWebhookRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
+import org.eclipsefoundation.git.eca.config.WebhooksConfig;
+import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
+import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
+import org.eclipsefoundation.git.eca.model.Commit;
+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.GitEcaParameterNames;
+import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses;
+import org.eclipsefoundation.git.eca.namespace.ProviderType;
+import org.eclipsefoundation.git.eca.service.GithubApplicationService;
+import org.eclipsefoundation.git.eca.service.ValidationService;
+import org.eclipsefoundation.persistence.dao.PersistenceDao;
+import org.eclipsefoundation.persistence.model.RDBMSQuery;
+import org.eclipsefoundation.persistence.service.FilterService;
+import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 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 GithubValidationHelper {
+    private static final Logger LOGGER = LoggerFactory.getLogger(GithubValidationHelper.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
+    ValidationService validation;
+    @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.
+     * @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) {
+        String fullRepoName = org + '/' + repoName;
+        // get the installation ID for the given repo if it exists, and if the PR noted exists
+        String installationId = ghAppService.getInstallationForRepo(fullRepoName);
+        Optional<PullRequest> prResponse = ghAppService.getPullRequest(installationId, fullRepoName, 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, fullRepoName, prNo, repoUrl);
+
+        // build the commit sha list based on the prepared request
+        List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).collect(Collectors.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, fullRepoName,
+                prResponse.get());
+        if (updatedTracking == null) {
+            throw new ServerErrorException("Error while attempting to revalidate request, try again later.",
+                    HttpStatus.SC_INTERNAL_SERVER_ERROR);
+        }
+
+        // get the commit status of commits to use for checking historic validation
+        List<CommitValidationStatus> statuses = validation.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 ("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 repositoryFullName, int pullRequestNumber,
+            String repositoryUrl) {
+        checkRequestParameters(installationId, repositoryFullName, pullRequestNumber);
+        // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful
+        List<GithubCommit> commits = middleware
+                .getAll(i -> ghApi
+                        .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, repositoryFullName, pullRequestNumber),
+                        GithubCommit.class);
+        LOGGER.trace("Retrieved {} commits for PR {} in repo {}", commits.size(), pullRequestNumber, repositoryUrl);
+        // 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).collect(Collectors.toList()))
+                                .build())
+                        .collect(Collectors.toList()))
+                .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) {
+        // update the status before processing
+        LOGGER
+                .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
+                        GithubCommitStatuses.PENDING);
+        updateCommitStatus(request, GithubCommitStatuses.PENDING);
+
+        // validate the response
+        LOGGER
+                .trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(),
+                        request.getPullRequest().getNumber());
+        ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
+        if (r.getPassed()) {
+            LOGGER
+                    .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
+                            GithubCommitStatuses.SUCCESS);
+            updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
+        } else {
+            LOGGER
+                    .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
+                            GithubCommitStatuses.FAILURE);
+            updateCommitStatus(request, GithubCommitStatuses.FAILURE);
+        }
+        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 repositoryFullName, PullRequest pr) {
+        return updateGithubTrackingIfMissing(wrapper,
+                getExistingRequestInformation(wrapper, installationId, repositoryFullName, pr.getNumber()), pr, installationId,
+                repositoryFullName);
+    }
+
+    /**
+     * 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 fullRepoName) {
+        // if there is no tracking present, create the missing tracking and persist it
+        GithubWebhookTracking updatedTracking;
+        if (tracking.isEmpty()) {
+            updatedTracking = new GithubWebhookTracking();
+            updatedTracking.setHeadSha(request.getHead().getSha());
+            updatedTracking.setInstallationId(installationId);
+            updatedTracking.setLastUpdated(DateTimeHelper.now());
+            updatedTracking.setPullRequestNumber(request.getNumber());
+            updatedTracking.setRepositoryFullName(fullRepoName);
+            updatedTracking.setLastKnownState(request.getState());
+            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 {
+            // at least update the state on every run
+            updatedTracking = tracking.get();
+            updatedTracking.setLastKnownState(request.getState());
+        }
+
+        // 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 repositoryFullName, int pullRequestNumber) {
+        checkRequestParameters(installationId, repositoryFullName, pullRequestNumber);
+        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
+        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();
+    }
+
+    /**
+     * 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) {
+        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().getFullName(), request.getPullRequest().getHead().getSha(),
+                        GithubCommitStatusRequest
+                                .builder()
+                                .setDescription(state.getMessage())
+                                .setState(state.toString())
+                                .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/"
+                                        + request.getRepository().getFullName() + '/' + request.getPullRequest().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 repositoryFullName, int pullRequestNumber) {
+        List<String> missingFields = new ArrayList<>();
+        if (StringUtils.isBlank(installationId)) {
+            missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
+        }
+        if (StringUtils.isBlank(repositoryFullName)) {
+            missingFields.add(GitEcaParameterNames.REPOSITORY_FULL_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, ' '));
+        }
+    }
+
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/namespace/GitEcaParameterNames.java b/src/main/java/org/eclipsefoundation/git/eca/namespace/GitEcaParameterNames.java
index a16499237e51d4313a1e7505d0e73f187f702327..cbfaa0ee2926ae4a6d13ae3b6cc4dd40d7cb0ad9 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/namespace/GitEcaParameterNames.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/namespace/GitEcaParameterNames.java
@@ -39,6 +39,7 @@ public final class GitEcaParameterNames implements UrlParameterNamespace {
     public static final String INSTALLATION_ID_RAW = "installation_id";
     public static final String PULL_REQUEST_NUMBER_RAW = "pull_request_number";
     public static final String USER_MAIL_RAW = "user_mail";
+    public static final String NEEDS_REVALIDATION_RAW = "needs_revalidation";
     public static final UrlParameter COMMIT_ID = new UrlParameter(COMMIT_ID_RAW);
     public static final UrlParameter SHA = new UrlParameter(SHA_RAW);
     public static final UrlParameter SHAS = new UrlParameter(SHAS_RAW);
@@ -58,13 +59,14 @@ public final class GitEcaParameterNames implements UrlParameterNamespace {
     public static final UrlParameter INSTALLATION_ID = new UrlParameter(INSTALLATION_ID_RAW);
     public static final UrlParameter PULL_REQUEST_NUMBER = new UrlParameter(PULL_REQUEST_NUMBER_RAW);
     public static final UrlParameter USER_MAIL = new UrlParameter(USER_MAIL_RAW);
+    public static final UrlParameter NEEDS_REVALIDATION = new UrlParameter(NEEDS_REVALIDATION_RAW);
 
     @Override
     public List<UrlParameter> getParameters() {
         return Arrays
                 .asList(COMMIT_ID, SHA, SHAS, PROJECT_ID, PROJECT_IDS, NOT_IN_PROJECT_IDS, REPO_URL, FINGERPRINT, USER_ID, PROJECT_PATH,
                         PARENT_PROJECT, STATUS_ACTIVE, STATUS_DELETED, SINCE, UNTIL, REPOSITORY_FULL_NAME, INSTALLATION_ID,
-                        PULL_REQUEST_NUMBER, USER_MAIL);
+                        PULL_REQUEST_NUMBER, USER_MAIL, NEEDS_REVALIDATION);
     }
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
index a7fc852eb799a75c9f58672b3a3648bad92c5c0c..6fd5d3a14addfc4d7b94c9c730750a6017830eaa 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
@@ -11,48 +11,16 @@
  */
 package org.eclipsefoundation.git.eca.resource;
 
-import java.net.URI;
-import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Supplier;
-import java.util.stream.Collectors;
 
 import javax.inject.Inject;
-import javax.ws.rs.BadRequestException;
-import javax.ws.rs.core.MultivaluedMap;
 
-import org.apache.commons.lang3.StringUtils;
-import org.eclipse.microprofile.config.inject.ConfigProperty;
-import org.eclipse.microprofile.rest.client.inject.RestClient;
 import org.eclipsefoundation.core.helper.DateTimeHelper;
 import org.eclipsefoundation.core.model.RequestWrapper;
-import org.eclipsefoundation.core.service.APIMiddleware;
-import org.eclipsefoundation.git.eca.api.GithubAPI;
-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.GithubWebhookRequest;
-import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
-import org.eclipsefoundation.git.eca.config.WebhooksConfig;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
-import org.eclipsefoundation.git.eca.helper.JwtHelper;
-import org.eclipsefoundation.git.eca.model.Commit;
-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.GitEcaParameterNames;
-import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses;
-import org.eclipsefoundation.git.eca.namespace.ProviderType;
-import org.eclipsefoundation.git.eca.service.GithubApplicationService;
-import org.eclipsefoundation.git.eca.service.ValidationService;
 import org.eclipsefoundation.persistence.dao.PersistenceDao;
 import org.eclipsefoundation.persistence.model.RDBMSQuery;
 import org.eclipsefoundation.persistence.service.FilterService;
-import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * Contains operations and properties that are common to resources that interact with Github validation.
@@ -61,258 +29,18 @@ import org.slf4j.LoggerFactory;
  *
  */
 public abstract class GithubAdjacentResource {
-    private static final Logger LOGGER = LoggerFactory.getLogger(GithubAdjacentResource.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
-    ValidationService validation;
-    @Inject
-    GithubApplicationService ghAppService;
-
     @Inject
     RequestWrapper wrapper;
 
-    @RestClient
-    GithubAPI ghApi;
-
-    /**
-     * 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")
-    ValidationRequest generateRequest(String installationId, String repositoryFullName, int pullRequestNumber, String repositoryUrl) {
-        checkRequestParameters(installationId, repositoryFullName, pullRequestNumber);
-        // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful
-        List<GithubCommit> commits = middleware
-                .getAll(i -> ghApi
-                        .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, repositoryFullName, pullRequestNumber),
-                        GithubCommit.class);
-        LOGGER.trace("Retrieved {} commits for PR {} in repo {}", commits.size(), pullRequestNumber, repositoryUrl);
-        // 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).collect(Collectors.toList()))
-                                .build())
-                        .collect(Collectors.toList()))
-                .build();
-    }
-
-    /**
-     * 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
-     */
-    GithubWebhookTracking retrieveAndUpdateTrackingInformation(String installationId, String repositoryFullName, PullRequest pr) {
-        return updateGithubTrackingIfMissing(getExistingRequestInformation(installationId, repositoryFullName, pr.getNumber()), pr,
-                installationId, repositoryFullName);
-    }
-
-    /**
-     * 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.
-     */
-    Optional<GithubWebhookTracking> getExistingRequestInformation(String installationId, String repositoryFullName, int pullRequestNumber) {
-        checkRequestParameters(installationId, repositoryFullName, pullRequestNumber);
-        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
-        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();
+    void setRevalidationFlagForTracking(GithubWebhookTracking tracking) {
+        tracking.setNeedsRevalidation(true);
+        tracking.setLastUpdated(DateTimeHelper.now());
+        dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(tracking));
     }
-
-    /**
-     * 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
-     */
-    GithubWebhookTracking updateGithubTrackingIfMissing(Optional<GithubWebhookTracking> tracking, PullRequest request,
-            String installationId, String fullRepoName) {
-        // if there is no tracking present, create the missing tracking and persist it
-        GithubWebhookTracking updatedTracking;
-        if (tracking.isEmpty()) {
-            updatedTracking = new GithubWebhookTracking();
-            updatedTracking.setHeadSha(request.getHead().getSha());
-            updatedTracking.setInstallationId(installationId);
-            updatedTracking.setLastUpdated(DateTimeHelper.now());
-            updatedTracking.setPullRequestNumber(request.getNumber());
-            updatedTracking.setRepositoryFullName(fullRepoName);
-            updatedTracking.setLastKnownState(request.getState());
-            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 {
-            // at least update the state on every run
-            updatedTracking = tracking.get();
-            updatedTracking.setLastKnownState(request.getState());
-        }
-
-        // 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 new GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber());
-        return savedTracking.get(0);
-    }
-
-    /**
-     * 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.
-     */
-    boolean handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr) {
-        // update the status before processing
-        LOGGER
-                .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
-                        GithubCommitStatuses.PENDING);
-        updateCommitStatus(request, GithubCommitStatuses.PENDING);
-
-        // validate the response
-        LOGGER
-                .trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(),
-                        request.getPullRequest().getNumber());
-        ValidationResponse r = validation.validateIncomingRequest(vr, wrapper);
-        if (r.getPassed()) {
-            LOGGER
-                    .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
-                            GithubCommitStatuses.SUCCESS);
-            updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
-            return true;
-        }
-        LOGGER
-                .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
-                        GithubCommitStatuses.FAILURE);
-        updateCommitStatus(request, GithubCommitStatuses.FAILURE);
-        return false;
-    }
-
-    /**
-     * 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) {
-        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().getFullName(), request.getPullRequest().getHead().getSha(),
-                        GithubCommitStatusRequest
-                                .builder()
-                                .setDescription(state.getMessage())
-                                .setState(state.toString())
-                                .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" + request.getRepository().getFullName() + '/'
-                                        + request.getPullRequest().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 repositoryFullName, int pullRequestNumber) {
-        List<String> missingFields = new ArrayList<>();
-        if (StringUtils.isBlank(installationId)) {
-            missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW);
-        }
-        if (StringUtils.isBlank(repositoryFullName)) {
-            missingFields.add(GitEcaParameterNames.REPOSITORY_FULL_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, ' '));
-        }
-    }
-
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
index f02a4015b2c7a0ca52dccc5431185caefd30ab62..86125bbbba346109e726dbe00ceb950909e6899f 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
@@ -25,15 +25,18 @@ import javax.ws.rs.QueryParam;
 import javax.ws.rs.ServerErrorException;
 import javax.ws.rs.core.Response;
 
+import org.apache.http.HttpStatus;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
 import org.eclipsefoundation.git.eca.helper.CaptchaHelper;
+import org.eclipsefoundation.git.eca.helper.GithubValidationHelper;
 import org.eclipsefoundation.git.eca.model.RevalidationResponse;
 import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
 import org.eclipsefoundation.git.eca.namespace.HCaptchaErrorCodes;
 import org.eclipsefoundation.git.eca.namespace.WebhookHeaders;
+import org.eclipsefoundation.git.eca.service.GithubApplicationService;
 import org.jboss.resteasy.annotations.jaxrs.HeaderParam;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -48,6 +51,11 @@ import org.slf4j.LoggerFactory;
 public class GithubWebhooksResource extends GithubAdjacentResource {
     private static final Logger LOGGER = LoggerFactory.getLogger(GithubWebhooksResource.class);
 
+    @Inject
+    GithubApplicationService ghAppService;
+
+    @Inject
+    GithubValidationHelper validationHelper;
     @Inject
     CaptchaHelper captchaHelper;
 
@@ -70,14 +78,23 @@ public class GithubWebhooksResource extends GithubAdjacentResource {
         if (!"pull_request".equalsIgnoreCase(eventType)) {
             return Response.ok().build();
         }
-        LOGGER.trace("Processing PR event for install {} with delivery ID of {}", request.getInstallation().getId(), deliveryId);
-        // prepare for validation process by pre-processing into standard format
-        ValidationRequest vr = generateRequest(request);
+
         // track the request before we start processing
-        retrieveAndUpdateTrackingInformation(request.getInstallation().getId(), request.getRepository().getFullName(),
-                request.getPullRequest());
-        // process the request
-        handleGithubWebhookValidation(request, vr);
+        GithubWebhookTracking tracking = validationHelper
+                .retrieveAndUpdateTrackingInformation(wrapper, request.getInstallation().getId(), request.getRepository().getFullName(),
+                        request.getPullRequest());
+
+        // start processing the request
+        LOGGER.trace("Processing PR event for install {} with delivery ID of {}", request.getInstallation().getId(), deliveryId);
+        try {
+            // prepare for validation process by pre-processing into standard format
+            ValidationRequest vr = generateRequest(request);
+            // process the request
+            validationHelper.handleGithubWebhookValidation(request, vr, wrapper);
+        } catch (Exception e) {
+            // set the revalidation flag to reprocess if the initial attempt fails before exiting
+            setRevalidationFlagForTracking(tracking);
+        }
 
         return Response.ok().build();
     }
@@ -105,11 +122,12 @@ public class GithubWebhooksResource extends GithubAdjacentResource {
         }
 
         // get the tracking if it exists, create it if it doesn't, and fail out if there is an issue
-        GithubWebhookTracking tracking = retrieveAndUpdateTrackingInformation(installationId, fullRepoName, prResponse.get());
+        GithubWebhookTracking tracking = validationHelper
+                .retrieveAndUpdateTrackingInformation(wrapper, installationId, fullRepoName, prResponse.get());
         if (tracking == null) {
             throw new ServerErrorException(
                     String.format("Cannot find a tracked pull request with for repo '%s', pull request number '%d'", fullRepoName, prNo),
-                    500);
+                    HttpStatus.SC_INTERNAL_SERVER_ERROR);
         } else if (!"open".equalsIgnoreCase(tracking.getLastKnownState())) {
             // we do not want to reprocess non-open pull requests
             throw new BadRequestException("Cannot revalidate a non-open pull request");
@@ -125,19 +143,25 @@ public class GithubWebhooksResource extends GithubAdjacentResource {
             throw new BadRequestException("hCaptcha challenge response failed for this request");
         }
 
-        // get the tracking class, convert back to a GH webhook request, and validate the request
-        GithubWebhookRequest request = GithubWebhookRequest.buildFromTracking(tracking);
-        boolean isSuccessful = handleGithubWebhookValidation(request, generateRequest(request));
-        LOGGER.debug("Revalidation for request for '{}#{}' was {}successful.", fullRepoName, prNo, isSuccessful ? "" : " not");
-
-        // build the url for pull request page
-        StringBuilder sb = new StringBuilder();
-        sb.append("https://github.com/");
-        sb.append(tracking.getRepositoryFullName());
-        sb.append("/pull/");
-        sb.append(tracking.getPullRequestNumber());
-        // respond with a URL to the new location in a standard request
-        return Response.ok(RevalidationResponse.builder().setLocation(URI.create(sb.toString())).build()).build();
+        // wrap the processing in a try-catch to catch upstream errors and recast them as server errors
+        try {
+            // get the tracking class, convert back to a GH webhook request, and validate the request
+            GithubWebhookRequest request = GithubWebhookRequest.buildFromTracking(tracking);
+            boolean isSuccessful = validationHelper.handleGithubWebhookValidation(request, generateRequest(request), wrapper).getPassed();
+            LOGGER.debug("Revalidation for request for '{}#{}' was {}successful.", fullRepoName, prNo, isSuccessful ? "" : " not");
+
+            // build the url for pull request page
+            StringBuilder sb = new StringBuilder();
+            sb.append("https://github.com/");
+            sb.append(fullRepoName);
+            sb.append("/pull/");
+            sb.append(prNo);
+            // respond with a URL to the new location in a standard request
+            return Response.ok(RevalidationResponse.builder().setLocation(URI.create(sb.toString())).build()).build();
+        } catch (Exception e) {
+            // rewrap exception, as some of the Github stuff can fail w/o explanation
+            throw new ServerErrorException(HttpStatus.SC_INTERNAL_SERVER_ERROR, e);
+        }
     }
 
     /**
@@ -147,8 +171,9 @@ public class GithubWebhooksResource extends GithubAdjacentResource {
      * @return the Validation Request body to be used in validating data
      */
     private ValidationRequest generateRequest(GithubWebhookRequest request) {
-        return generateRequest(request.getInstallation().getId(), request.getRepository().getFullName(),
-                request.getPullRequest().getNumber(), request.getRepository().getHtmlUrl());
+        return validationHelper
+                .generateRequest(request.getInstallation().getId(), request.getRepository().getFullName(),
+                        request.getPullRequest().getNumber(), request.getRepository().getHtmlUrl());
     }
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
index 8fd1b44f975684f587066fbad69bf7b836aaafbd..aeef62979e8229cbbb52fdce5d5b89de391eeb17 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
@@ -12,31 +12,24 @@
 package org.eclipsefoundation.git.eca.resource;
 
 import java.util.List;
-import java.util.Optional;
 import java.util.stream.Collectors;
 
 import javax.inject.Inject;
-import javax.ws.rs.BadRequestException;
 import javax.ws.rs.GET;
 import javax.ws.rs.NotFoundException;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
-import javax.ws.rs.ServerErrorException;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 
-import org.apache.commons.lang3.StringUtils;
 import org.eclipsefoundation.efservices.api.models.Project;
-import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
-import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
-import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
+import org.eclipsefoundation.git.eca.helper.GithubValidationHelper;
 import org.eclipsefoundation.git.eca.helper.ProjectHelper;
-import org.eclipsefoundation.git.eca.model.Commit;
-import org.eclipsefoundation.git.eca.model.ValidationRequest;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipsefoundation.git.eca.model.ValidationResponse;
+import org.eclipsefoundation.git.eca.service.GithubApplicationService;
+import org.eclipsefoundation.git.eca.service.ValidationService;
 
 import io.quarkus.qute.Location;
 import io.quarkus.qute.Template;
@@ -49,8 +42,14 @@ import io.quarkus.qute.Template;
  */
 @Path("eca/status")
 public class StatusResource extends GithubAdjacentResource {
-    private static final Logger LOGGER = LoggerFactory.getLogger(StatusResource.class);
 
+    @Inject
+    GithubApplicationService ghAppService;
+    @Inject
+    ValidationService validation;
+
+    @Inject
+    GithubValidationHelper validationHelper;
     @Inject
     ProjectHelper projects;
 
@@ -113,49 +112,12 @@ public class StatusResource extends GithubAdjacentResource {
     @Path("gh/{org}/{repoName}/{prNo}")
     public Response getCommitValidationForGithub(@PathParam("org") String org, @PathParam("repoName") String repoName,
             @PathParam("prNo") Integer prNo) {
-        String fullRepoName = org + '/' + repoName;
-        // get the installation ID for the given repo if it exists, and if the PR noted exists
-        String installationId = ghAppService.getInstallationForRepo(fullRepoName);
-        Optional<PullRequest> prResponse = ghAppService.getPullRequest(installationId, fullRepoName, 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 = "https://github.com/" + fullRepoName;
-        ValidationRequest vr = generateRequest(installationId, fullRepoName, prNo, repoUrl);
-
-        // build the commit sha list based on the prepared request
-        List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).collect(Collectors.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(installationId, fullRepoName, prResponse.get());
-        if (updatedTracking == null) {
-            throw new ServerErrorException("Error while attempting to revalidate request, try again later.", 500);
-        }
-
-        // get the commit status of commits to use for checking historic validation
-        List<CommitValidationStatus> statuses = validation.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 ("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
-            handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr);
-            // call to retrieve the statuses once again since they will have changed at this point
-            statuses = validation.getHistoricValidationStatusByShas(wrapper, commitShas);
-        }
+        // run the validation for the current request
+        ValidationResponse r = validationHelper.validateIncomingRequest(wrapper, org, repoName, prNo);
 
+        // retrieve the status of the commits to display on the status page
+        List<CommitValidationStatus> statuses = validation
+                .getHistoricValidationStatusByShas(wrapper, r.getCommits().keySet().stream().collect(Collectors.toList()));
         // get projects for use in status UI
         List<Project> ps = projects.retrieveProjectsForRepoURL(statuses.get(0).getRepoUrl(), statuses.get(0).getProvider());
         // render and return the status UI
@@ -164,10 +126,10 @@ public class StatusResource extends GithubAdjacentResource {
                 .entity(statusUiTemplate
                         .data("statuses", statuses)
                         .data("pullRequestNumber", prNo)
-                        .data("fullRepoName", fullRepoName)
+                        .data("fullRepoName", org + '/' + repoName)
                         .data("project", ps.isEmpty() ? null : ps.get(0))
-                        .data("repoUrl", repoUrl)
-                        .data("installationId", installationId)
+                        .data("repoUrl", GithubValidationHelper.getRepoUrl(org, repoName))
+                        .data("installationId", ghAppService.getInstallationForRepo(org + '/' + repoName))
                         .render())
                 .build();
     }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/tasks/GithubRevalidationQueue.java b/src/main/java/org/eclipsefoundation/git/eca/tasks/GithubRevalidationQueue.java
new file mode 100644
index 0000000000000000000000000000000000000000..cfaedf155b5713945bbcabbd231045b1dbc2013b
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/tasks/GithubRevalidationQueue.java
@@ -0,0 +1,143 @@
+/**
+ * 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: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.tasks;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+import javax.enterprise.context.ApplicationScoped;
+import javax.enterprise.context.control.ActivateRequestContext;
+import javax.enterprise.inject.Instance;
+import javax.inject.Inject;
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipsefoundation.core.helper.DateTimeHelper;
+import org.eclipsefoundation.core.model.FlatRequestWrapper;
+import org.eclipsefoundation.core.model.RequestWrapper;
+import org.eclipsefoundation.core.namespace.DefaultUrlParameterNames;
+import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
+import org.eclipsefoundation.git.eca.helper.GithubValidationHelper;
+import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
+import org.eclipsefoundation.persistence.dao.PersistenceDao;
+import org.eclipsefoundation.persistence.model.RDBMSQuery;
+import org.eclipsefoundation.persistence.namespace.PersistenceUrlParameterNames;
+import org.eclipsefoundation.persistence.service.FilterService;
+import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkus.scheduler.Scheduled;
+
+/**
+ * Scheduled regular task that will interact with the backend persistence to look for requests that are in a
+ * failed/unvalidated state after an error while processing that could not be recovered. These requests will be
+ * reprocessed using the same logic as the standard validation, updating the timestamp on completion and either setting
+ * the revalidation flag to false or incrementing the number of repeated revalidations needed for the request for
+ * tracking, depending on the succcess of the revalidation.
+ */
+@ApplicationScoped
+public class GithubRevalidationQueue {
+    private static final Logger LOGGER = LoggerFactory.getLogger(GithubRevalidationQueue.class);
+
+    @ConfigProperty(name = "eclipse.git-eca.tasks.gh-revalidation.enabled", defaultValue = "true")
+    Instance<Boolean> isEnabled;
+
+    @Inject
+    PersistenceDao dao;
+    @Inject
+    FilterService filters;
+
+    @Inject
+    GithubValidationHelper validationHelper;
+
+    @PostConstruct
+    void init() {
+        // indicate to log whether enabled to reduce log spam
+        LOGGER.info("Github revalidation queue task is{} enabled.", Boolean.TRUE.equals(isEnabled.get()) ? "" : " not");
+    }
+
+    /**
+     * Every 5s, this method will attempt to load a Github webhook validation request that has the needs revalidation flag
+     * set to true. This will retrieve the oldest request in queue and will attempt to revalidate it.
+     */
+    @Scheduled(every = "5s")
+    @ActivateRequestContext
+    public void revalidate() {
+        // if not enabled, don't process any potentially OOD records
+        if (!Boolean.TRUE.equals(isEnabled.get())) {
+            return;
+        }
+
+        // set up params for looking up the top of the revalidation queue
+        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
+        params.add(GitEcaParameterNames.NEEDS_REVALIDATION_RAW, "true");
+        params.add(PersistenceUrlParameterNames.SORT.getName(), "lastUpdated");
+        params.add(DefaultUrlParameterNames.PAGESIZE.getName(), "1");
+
+        // build the request and query to lookup the longest standing request that needs revalidation
+        RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/eca/revalidation-queue"));
+        RDBMSQuery<GithubWebhookTracking> trackingQuery = new RDBMSQuery<>(wrap, filters.get(GithubWebhookTracking.class), params);
+
+        List<GithubWebhookTracking> oldestRevalidation = dao.get(trackingQuery);
+        if (oldestRevalidation.isEmpty()) {
+            LOGGER.debug("No queued revalidation requests found");
+        } else {
+            reprocessRequest(oldestRevalidation.get(0), wrap);
+        }
+    }
+
+    /**
+     * Reprocess the given record, attempting to run the ECA validation logic again. If it passes, the revalidation flag is
+     * set to false and the time code is updated. If the processing fails again, the failure count gets incremented and the
+     * updated time is set so that another entry can be updated, as to not block on potentially broken records.
+     * 
+     * @param requestToRevalidate the webhook tracking request to attempt to revalidate
+     * @param wrap the current stubbed request wrapper used for queries.
+     */
+    private void reprocessRequest(GithubWebhookTracking requestToRevalidate, RequestWrapper wrap) {
+        LOGGER
+                .debug("Attempting revalidation of request w/ ID {}, in repo {}#{}", requestToRevalidate.getId(),
+                        requestToRevalidate.getRepositoryFullName(), requestToRevalidate.getPullRequestNumber());
+
+        // wrap in try-catch to avoid errors from stopping the record updates
+        try {
+            // split the full repo name into the org and repo name
+            String[] repoFullNameParts = requestToRevalidate.getRepositoryFullName().split("/");
+            if (repoFullNameParts.length != 2) {
+                throw new IllegalStateException("Record with ID '" + Long.toString(requestToRevalidate.getId())
+                        + "' is in an invalid state (repository full name is not valid)");
+            }
+
+            // run the validation and then check if it succeeded
+            validationHelper
+                    .validateIncomingRequest(wrap, repoFullNameParts[0], repoFullNameParts[1], requestToRevalidate.getPullRequestNumber());
+            // if we have gotten here, then the validation has completed and can be removed from queue
+            requestToRevalidate.setNeedsRevalidation(false);
+            LOGGER.debug("Sucessfully revalidated request w/ ID {}", requestToRevalidate.getId());
+        } catch (RuntimeException e) {
+            // update the number of times this status has revalidated (tracking)
+            requestToRevalidate
+                    .setManualRevalidationCount(requestToRevalidate.getManualRevalidationCount() == null ? 1
+                            : requestToRevalidate.getManualRevalidationCount() + 1);
+            // log the message so we can see what happened
+            LOGGER.error("Error while revalidating request w/ ID {}", requestToRevalidate.getId(), e);
+        } finally {
+            // whether successful or failed, update the updated field
+            requestToRevalidate.setLastUpdated(DateTimeHelper.now());
+            // push the update with the potentially updated validation flag or error count
+            dao.add(new RDBMSQuery<>(wrap, filters.get(GithubWebhookTracking.class)), Arrays.asList(requestToRevalidate));
+        }
+    }
+}
diff --git a/src/test/java/org/eclipsefoundation/git/eca/resource/ReportsResourceTest.java b/src/test/java/org/eclipsefoundation/git/eca/resource/ReportsResourceTest.java
index 8def7dcf42cb21cacafe2cbd42a365eb1537380a..06c6ee66a0b4191e24c887db1951b20ce4c3ad9d 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/resource/ReportsResourceTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/resource/ReportsResourceTest.java
@@ -13,6 +13,7 @@ package org.eclipsefoundation.git.eca.resource;
 
 import java.util.Optional;
 
+import org.apache.http.HttpStatus;
 import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
 import org.eclipsefoundation.git.eca.test.namespaces.SchemaNamespaceHelper;
 import org.eclipsefoundation.testing.helpers.TestCaseHelper;
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index ed2bc8fb08d1992cd53873ad2ad94b65c3eb3d10..4b362d9386195a064dfb14940077dce551c3236d 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -34,6 +34,7 @@ eclipse.git-eca.mail.allow-list=noreply@github.com
 
 ## Reports configs
 eclipse.git-eca.reports.access-key=samplekey
+eclipse.git-eca.tasks.gh-revalidation.enabled=false
 
 ## Misc
 eclipse.gitlab.access-token=token_val
diff --git a/src/test/resources/database/default/V1.0.0__default.sql b/src/test/resources/database/default/V1.0.0__default.sql
index 100590d363360f140356726c5d366cfe12a9e56d..25e97153916cc74045080f8e33aaacad9c09fb8f 100644
--- a/src/test/resources/database/default/V1.0.0__default.sql
+++ b/src/test/resources/database/default/V1.0.0__default.sql
@@ -56,5 +56,7 @@ CREATE TABLE GithubWebhookTracking (
   repositoryFullName varchar(127) NOT NULL,
   pullRequestNumber int NOT NULL,
   lastUpdated datetime DEFAULT NULL,
+  needsRevalidation BOOLEAN DEFAULT FALSE,
+  manualRevalidationCount int DEFAULT 0,
   PRIMARY KEY (id)
 );
\ No newline at end of file