From 5d370febfc417f5ae0daa054d513cabd88c3ad90 Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Wed, 29 Mar 2023 13:57:10 -0400
Subject: [PATCH 1/6] Add new status URL for GH, remove fingerprint logic from
 GH webhook code

To better target data and remove some outdated tracking methods, the
previous fingerprint mechanism has been removed from the GH webhook
code. Additionally, a new status URL matching the format
git/eca/status/gh/{fullRepoUrl}/{prNumber} has been added to remove the
need for a fingerprint.
---
 pom.xml                                       |   2 +-
 .../git/eca/api/GithubAPI.java                |  30 +++-
 .../models/GithubApplicationInstallation.java |  49 ++++++
 ...ithubInstallationRepositoriesResponse.java |  45 +++++
 .../git/eca/dto/CommitValidationStatus.java   |  36 ++--
 .../git/eca/dto/GithubWebhookTracking.java    |  18 ++
 .../git/eca/helper/JwtHelper.java             |   2 +-
 .../git/eca/model/Commit.java                 |  61 +++----
 .../eca/namespace/GitEcaParameterNames.java   |  14 +-
 .../eca/resource/GithubAdjacentResource.java  | 159 ++++++++++++++++++
 .../eca/resource/GithubWebhooksResource.java  | 136 ++++-----------
 .../git/eca/resource/StatusResource.java      | 141 ++++++++++++++++
 .../eca/service/GithubApplicationService.java |  36 ++++
 .../git/eca/service/ValidationService.java    |   9 +
 .../impl/DefaultGithubApplicationService.java | 143 ++++++++++++++++
 .../impl/DefaultValidationService.java        |  33 +++-
 .../templates/simple_fingerprint_ui.html      |  12 +-
 .../git/eca/helper/CommitHelperTest.java      |  21 ---
 .../database/default/V1.0.0__default.sql      |   1 +
 19 files changed, 767 insertions(+), 181 deletions(-)
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubInstallationRepositoriesResponse.java
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java
 create mode 100644 src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java

diff --git a/pom.xml b/pom.xml
index 9db3d20b..85c87b1f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
   <artifactId>git-eca</artifactId>
   <version>1.1.0</version>
   <properties>
-    <eclipse-api-version>0.7.1</eclipse-api-version>
+    <eclipse-api-version>0.7.3-SNAPSHOT</eclipse-api-version>
     <compiler-plugin.version>3.8.1</compiler-plugin.version>
     <maven.compiler.parameters>true</maven.compiler.parameters>
     <maven.compiler.source>11</maven.compiler.source>
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 4f4032b4..fb9cc868 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -11,6 +11,7 @@
  */
 package org.eclipsefoundation.git.eca.api;
 
+import javax.ws.rs.BeanParam;
 import javax.ws.rs.GET;
 import javax.ws.rs.HeaderParam;
 import javax.ws.rs.POST;
@@ -20,6 +21,7 @@ import javax.ws.rs.Produces;
 import javax.ws.rs.core.Response;
 
 import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters;
 import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.jboss.resteasy.util.HttpHeaderNames;
@@ -34,6 +36,12 @@ import org.jboss.resteasy.util.HttpHeaderNames;
 @Produces("application/json")
 public interface GithubAPI {
 
+    @GET
+    @Path("repos/{repoFull}/pulls/{pullNumber}")
+    public Response getPullRequest(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("repoFull") String repoFull,
+            @PathParam("pullNumber") int pullNumber);
+
     @GET
     @Path("repos/{repoFull}/pulls/{pullNumber}/commits")
     public Response getCommits(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
@@ -42,11 +50,29 @@ public interface GithubAPI {
 
     @POST
     @Path("repos/{repoFull}/statuses/{prHeadSha}")
-    public Response updateStatus(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("repoFull") String repoFull, @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate);
+    public Response updateStatus(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("repoFull") String repoFull,
+            @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 bearer JWT bearer token for the target application
+     * @return list of installations for the application
+     */
+    @GET
+    @Path("app/installations")
+    public Response getInstallations(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer);
 
     @POST
     @Path("app/installations/{installationId}/access_tokens")
     public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("installationId") String installationId);
+
+    @GET
+    @Path("installation/repositories")
+    public Response getInstallationRepositories(@BeanParam BaseAPIParameters params,
+            @HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer);
+
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java
new file mode 100644
index 00000000..7315e390
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java
@@ -0,0 +1,49 @@
+/**
+ * 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.api.models;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+/**
+ * Information about the current
+ * 
+ * @author Martin Lowe
+ *
+ */
+@AutoValue
+@JsonDeserialize(builder = AutoValue_GithubApplicationInstallation.Builder.class)
+public abstract class GithubApplicationInstallation {
+
+    public abstract int getId();
+
+    public abstract String getTargetType();
+
+    public abstract String getTargetId();
+
+    public static Builder builder() {
+        return new AutoValue_GithubApplicationInstallation.Builder();
+    }
+
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+        public abstract Builder setId(int id);
+
+        public abstract Builder setTargetType(String targetType);
+
+        public abstract Builder setTargetId(String targetId);
+
+        public abstract GithubApplicationInstallation build();
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubInstallationRepositoriesResponse.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubInstallationRepositoriesResponse.java
new file mode 100644
index 00000000..7c81446e
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubInstallationRepositoriesResponse.java
@@ -0,0 +1,45 @@
+/**
+ * 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.api.models;
+
+import java.util.List;
+
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.Repository;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+/**
+ * Model response for /installations/repositories
+ * 
+ * @author Martin Lowe
+ */
+@AutoValue
+@JsonDeserialize(builder = AutoValue_GithubInstallationRepositoriesResponse.Builder.class)
+public abstract class GithubInstallationRepositoriesResponse {
+
+    public abstract List<Repository> getRepositories();
+
+    public static Builder builder() {
+        return new AutoValue_GithubInstallationRepositoriesResponse.Builder();
+    }
+
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+
+        public abstract Builder setRepositories(List<Repository> repository);
+
+        public abstract GithubInstallationRepositoriesResponse build();
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/dto/CommitValidationStatus.java b/src/main/java/org/eclipsefoundation/git/eca/dto/CommitValidationStatus.java
index 14a21fc8..7c82d88f 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/dto/CommitValidationStatus.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/dto/CommitValidationStatus.java
@@ -46,6 +46,7 @@ public class CommitValidationStatus extends BareNode {
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Long id;
     private String commitHash;
+    private String userMail;
     private String project;
     private String repoUrl;
     @Enumerated(EnumType.STRING)
@@ -79,6 +80,20 @@ public class CommitValidationStatus extends BareNode {
         this.commitHash = commitHash;
     }
 
+    /**
+     * @return the userMail
+     */
+    public String getUserMail() {
+        return userMail;
+    }
+
+    /**
+     * @param userMail the userMail to set
+     */
+    public void setUserMail(String userMail) {
+        this.userMail = userMail;
+    }
+
     /**
      * @return the project
      */
@@ -184,6 +199,8 @@ public class CommitValidationStatus extends BareNode {
         builder.append(id);
         builder.append(", sha=");
         builder.append(commitHash);
+        builder.append(", userMail=");
+        builder.append(userMail);
         builder.append(", project=");
         builder.append(project);
         builder.append(", repoUrl=");
@@ -213,26 +230,25 @@ public class CommitValidationStatus extends BareNode {
             // sha check
             String commitHash = params.getFirst(GitEcaParameterNames.SHA.getName());
             if (StringUtils.isNumeric(commitHash)) {
-                stmt.addClause(
-                        new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".commitHash = ?",
-                                new Object[] { commitHash }));
+                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".commitHash = ?", new Object[] { commitHash }));
             }
             String projectId = params.getFirst(GitEcaParameterNames.PROJECT_ID.getName());
             if (StringUtils.isNumeric(projectId)) {
-                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".projectId = ?",
-                        new Object[] { projectId }));
+                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".projectId = ?", new Object[] { projectId }));
             }
             List<String> commitHashes = params.get(GitEcaParameterNames.SHAS.getName());
             if (commitHashes != null && !commitHashes.isEmpty()) {
-                stmt.addClause(
-                        new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".commitHash IN ?",
-                                new Object[] { commitHashes }));
+                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".commitHash IN ?", new Object[] { commitHashes }));
             }
             String repoUrl = params.getFirst(GitEcaParameterNames.REPO_URL.getName());
             if (StringUtils.isNotBlank(repoUrl)) {
-                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".repoUrl = ?",
-                        new Object[] { repoUrl }));
+                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".repoUrl = ?", new Object[] { repoUrl }));
             }
+            String userMail = params.getFirst(GitEcaParameterNames.USER_MAIL.getName());
+            if (StringUtils.isNotBlank(userMail)) {
+                stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".userMail = ?", new Object[] { userMail }));
+            }
+
             return stmt;
         }
 
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 41357937..e1c5ebe1 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
@@ -209,6 +209,24 @@ public class GithubWebhookTracking extends BareNode {
                         .addClause(
                                 new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".fingerprint = ?", new Object[] { fingerprint }));
             }
+            String installationId = params.getFirst(GitEcaParameterNames.INSTALLATION_ID_RAW);
+            if (StringUtils.isNotBlank(installationId)) {
+                statement
+                        .addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".installationId = ?",
+                                new Object[] { installationId }));
+            }
+            String pullRequestNumber = params.getFirst(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW);
+            if (StringUtils.isNumeric(pullRequestNumber)) {
+                statement
+                        .addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".pullRequestNumber = ?",
+                                new Object[] { Integer.parseInt(pullRequestNumber) }));
+            }
+            String repositoryFullName = params.getFirst(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW);
+            if (StringUtils.isNotBlank(repositoryFullName)) {
+                statement
+                        .addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".repositoryFullName = ?",
+                                new Object[] { repositoryFullName }));
+            }
 
             return statement;
         }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java
index 33d22c81..3fd168db 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java
@@ -80,7 +80,7 @@ public class JwtHelper {
      * 
      * @return signed JWT using the issuer and secret defined in the secret properties.
      */
-    private String generateJwt() {
+    public String generateJwt() {
         return Jwt.subject("EclipseWebmaster").sign(JwtHelper.getExternalPrivateKey(location));
     }
 
diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/Commit.java b/src/main/java/org/eclipsefoundation/git/eca/model/Commit.java
index e13ed008..a140d7d0 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/model/Commit.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/model/Commit.java
@@ -11,6 +11,7 @@
 **********************************************************************/
 package org.eclipsefoundation.git.eca.model;
 
+import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -29,48 +30,50 @@ import com.google.auto.value.AutoValue;
 @AutoValue
 @JsonDeserialize(builder = AutoValue_Commit.Builder.class)
 public abstract class Commit {
-	@Nullable
-	public abstract String getHash();
+    public abstract String getHash();
 
-	@Nullable
-	public abstract String getSubject();
+    @Nullable
+    public abstract String getSubject();
 
-	@Nullable
-	public abstract String getBody();
+    @Nullable
+    public abstract String getBody();
 
-	@Nullable
-	public abstract List<String> getParents();
+    @Nullable
+    public abstract List<String> getParents();
 
-	@Nullable
-	public abstract GitUser getAuthor();
+    public abstract GitUser getAuthor();
 
-	@Nullable
-	public abstract GitUser getCommitter();
+    public abstract GitUser getCommitter();
 
-	@Nullable
-	public abstract Boolean getHead();
+    @Nullable
+    public abstract Boolean getHead();
 
-	public static Builder builder() {
-		return new AutoValue_Commit.Builder().setParents(new ArrayList<>());
-	}
+    @Nullable
+    public abstract ZonedDateTime getLastModificationDate();
 
-	@AutoValue.Builder
-	@JsonPOJOBuilder(withPrefix = "set")
-	public abstract static class Builder {
-		public abstract Builder setHash(@Nullable String hash);
+    public static Builder builder() {
+        return new AutoValue_Commit.Builder().setParents(new ArrayList<>());
+    }
 
-		public abstract Builder setSubject(@Nullable String subject);
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+        public abstract Builder setHash(String hash);
 
-		public abstract Builder setBody(@Nullable String body);
+        public abstract Builder setSubject(@Nullable String subject);
 
-		public abstract Builder setParents(@Nullable List<String> parents);
+        public abstract Builder setBody(@Nullable String body);
 
-		public abstract Builder setAuthor(@Nullable GitUser author);
+        public abstract Builder setParents(@Nullable List<String> parents);
 
-		public abstract Builder setCommitter(@Nullable GitUser committer);
+        public abstract Builder setAuthor(GitUser author);
 
-		public abstract Builder setHead(@Nullable Boolean head);
+        public abstract Builder setCommitter(GitUser committer);
 
-		public abstract Commit build();
-	}
+        public abstract Builder setHead(@Nullable Boolean head);
+
+        public abstract Builder setLastModificationDate(@Nullable ZonedDateTime lastModificationDate);
+
+        public abstract Commit build();
+    }
 }
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 e75ca170..a1649923 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/namespace/GitEcaParameterNames.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/namespace/GitEcaParameterNames.java
@@ -35,6 +35,10 @@ public final class GitEcaParameterNames implements UrlParameterNamespace {
     public static final String STATUS_DELETED_RAW = "deleted";
     public static final String SINCE_RAW = "since";
     public static final String UNTIL_RAW = "until";
+    public static final String REPOSITORY_FULL_NAME_RAW = "repository_full_name";
+    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 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);
@@ -50,11 +54,17 @@ public final class GitEcaParameterNames implements UrlParameterNamespace {
     public static final UrlParameter STATUS_DELETED = new UrlParameter(STATUS_DELETED_RAW);
     public static final UrlParameter SINCE = new UrlParameter(SINCE_RAW);
     public static final UrlParameter UNTIL = new UrlParameter(UNTIL_RAW);
+    public static final UrlParameter REPOSITORY_FULL_NAME = new UrlParameter(REPOSITORY_FULL_NAME_RAW);
+    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);
 
     @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);
+        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);
     }
 
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
new file mode 100644
index 00000000..153e062f
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
@@ -0,0 +1,159 @@
+/**
+ * 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.resource;
+
+import java.net.URI;
+import java.util.ArrayList;
+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.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.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.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.namespace.GitEcaParameterNames;
+import org.eclipsefoundation.git.eca.namespace.ProviderType;
+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.
+ * 
+ * @author Martin Lowe
+ *
+ */
+public abstract class GithubAdjacentResource {
+    private static final Logger LOGGER = LoggerFactory.getLogger(GithubAdjacentResource.class);
+
+    @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
+    String apiVersion;
+
+    @Inject
+    JwtHelper jwtHelper;
+    @Inject
+    APIMiddleware middleware;
+    @Inject
+    PersistenceDao dao;
+    @Inject
+    FilterService filters;
+
+    @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
+     */
+    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(c.getCommit().getAuthor().getEmail())
+                                        .setName(c.getCommit().getAuthor().getName())
+                                        .build())
+                                .setCommitter(GitUser
+                                        .builder()
+                                        .setMail(c.getCommit().getCommitter().getEmail())
+                                        .setName(c.getCommit().getCommitter().getName())
+                                        .build())
+                                .build())
+                        .collect(Collectors.toList()))
+                .build();
+    }
+
+    /**
+     * 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();
+    }
+
+    /**
+     * 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 088f3659..1d6c927e 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
@@ -15,7 +15,6 @@ import java.net.URI;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
-import java.util.stream.Collectors;
 
 import javax.inject.Inject;
 import javax.ws.rs.BadRequestException;
@@ -23,38 +22,25 @@ import javax.ws.rs.FormParam;
 import javax.ws.rs.NotFoundException;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
-import javax.ws.rs.PathParam;
-import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Response;
 
 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.core.service.CachingService;
-import org.eclipsefoundation.git.eca.api.GithubAPI;
-import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
 import org.eclipsefoundation.git.eca.helper.CaptchaHelper;
-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.RevalidationResponse;
 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.HCaptchaErrorCodes;
-import org.eclipsefoundation.git.eca.namespace.ProviderType;
 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.annotations.jaxrs.HeaderParam;
-import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -65,7 +51,7 @@ import org.slf4j.LoggerFactory;
  *
  */
 @Path("webhooks/github")
-public class GithubWebhooksResource {
+public class GithubWebhooksResource extends GithubAdjacentResource {
     private static final Logger LOGGER = LoggerFactory.getLogger(GithubWebhooksResource.class);
 
     private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}";
@@ -74,29 +60,14 @@ public class GithubWebhooksResource {
     String context;
     @ConfigProperty(name = "eclipse.webhooks.github.server-target")
     String serverTarget;
-    @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
-    String apiVersion;
 
-    @Inject
-    RequestWrapper wrapper;
-    @Inject
-    APIMiddleware middleware;
     @Inject
     ValidationService validationService;
     @Inject
-    JwtHelper jwtHelper;
-    @Inject
     CaptchaHelper captchaHelper;
 
     @Inject
     CachingService cache;
-    @Inject
-    PersistenceDao dao;
-    @Inject
-    FilterService filters;
-
-    @RestClient
-    GithubAPI ghApi;
 
     /**
      * Entry point for processing Github webhook requests. Accepts standard fields as described in the <a href=
@@ -120,11 +91,10 @@ public class GithubWebhooksResource {
         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);
-        String fingerprint = validationService.generateRequestHash(vr);
         // track the request before we start processing
-        trackWebhookRequest(fingerprint, deliveryId, request);
+        trackWebhookRequest(deliveryId, request);
         // process the request
-        handleGithubWebhookValidation(request, vr, fingerprint);
+        handleGithubWebhookValidation(request, vr);
 
         return Response.ok().build();
     }
@@ -138,32 +108,36 @@ public class GithubWebhooksResource {
      * @return redirect to the pull request once done processing
      */
     @POST
-    @Path("revalidate/{fingerprint}")
-    public Response revalidateWebhookRequest(@PathParam("fingerprint") String fingerprint,
+    @Path("revalidate")
+    public Response revalidateWebhookRequest(@QueryParam(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW) String fullRepoName,
+            @QueryParam(GitEcaParameterNames.INSTALLATION_ID_RAW) String installationId,
+            @QueryParam(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW) Integer prNo,
             @FormParam("h-captcha-response") String captchaResponse) {
+        // get the tracking if it exists
+        Optional<GithubWebhookTracking> optTracking = getExistingRequestInformation(installationId, fullRepoName, prNo);
+        if (optTracking.isEmpty()) {
+            throw new NotFoundException(
+                    String.format("Cannot find a tracked pull request with for repo '%s', pull request number '%d'", fullRepoName, prNo));
+        }
+
         // check the captcha challenge response
         List<HCaptchaErrorCodes> errors = captchaHelper.validateCaptchaResponse(captchaResponse);
         if (!errors.isEmpty()) {
             // use debug logging as this could be incredibly noisy
             LOGGER
-                    .debug("Captcha challenge failed with the following errors for request with fingerprint '{}': {}", fingerprint,
-                            errors.stream().map(HCaptchaErrorCodes::getMessage));
+                    .debug("Captcha challenge failed with the following errors for revalidation request for '{}#{}': {}", fullRepoName,
+                            prNo, errors.stream().map(HCaptchaErrorCodes::getMessage));
             throw new BadRequestException("hCaptcha challenge response failed for this request");
         }
 
-        Optional<GithubWebhookTracking> optTracking = findTrackedRequest(fingerprint);
-        if (optTracking.isEmpty()) {
-            throw new NotFoundException("Cannot find a tracked pull request with fingerprint " + fingerprint);
-        }
-
         // get the tracking class, convert back to a GH webhook request, and validate the request
         GithubWebhookTracking tracking = optTracking.get();
         GithubWebhookRequest request = GithubWebhookRequest.buildFromTracking(tracking);
-        boolean isSuccessful = handleGithubWebhookValidation(request, generateRequest(request), fingerprint);
-        LOGGER.debug("Revalidation for request with fingerprint '{}' was {}successful.", fingerprint, isSuccessful ? "" : " not");
+        boolean isSuccessful = handleGithubWebhookValidation(request, generateRequest(request));
+        LOGGER.debug("Revalidation for request for '{}#{}' was {}successful.", fullRepoName, prNo, isSuccessful ? "" : " not");
 
         // update the tracking for the update time
-        trackWebhookRequest(fingerprint, tracking.getDeliveryId(), request);
+        trackWebhookRequest(tracking.getDeliveryId(), request);
         // build the url for pull request page
         StringBuilder sb = new StringBuilder();
         sb.append("https://github.com/");
@@ -182,15 +156,14 @@ public class GithubWebhooksResource {
      * 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 fingerprint the generated SHA hash for the request
      * @return true if the validation passed, false otherwise.
      */
-    private boolean handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr, String fingerprint) {
+    private 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, fingerprint);
+        updateCommitStatus(request, GithubCommitStatuses.PENDING);
 
         // validate the response
         LOGGER
@@ -201,13 +174,13 @@ public class GithubWebhooksResource {
             LOGGER
                     .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
                             GithubCommitStatuses.SUCCESS);
-            updateCommitStatus(request, GithubCommitStatuses.SUCCESS, fingerprint);
+            updateCommitStatus(request, GithubCommitStatuses.SUCCESS);
             return true;
         }
         LOGGER
                 .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
                         GithubCommitStatuses.FAILURE);
-        updateCommitStatus(request, GithubCommitStatuses.FAILURE, fingerprint);
+        updateCommitStatus(request, GithubCommitStatuses.FAILURE);
         return false;
     }
 
@@ -218,7 +191,7 @@ public class GithubWebhooksResource {
      * @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, String fingerprint) {
+    private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) {
         LOGGER
                 .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
                         jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
@@ -229,7 +202,8 @@ public class GithubWebhooksResource {
                                 .builder()
                                 .setDescription(state.getMessage())
                                 .setState(state.toString())
-                                .setTargetUrl(serverTarget + "/git/eca/status/" + fingerprint + "/ui")
+                                .setTargetUrl(serverTarget + "/git/eca/status/gh" + request.getRepository().getFullName() + '/'
+                                        + request.getPullRequest().getNumber())
                                 .setContext(context)
                                 .build());
     }
@@ -243,10 +217,10 @@ public class GithubWebhooksResource {
      * @param request the webhook event payload from Github
      * @return the persisted webhook tracking data, or null if there was an error in creating the tracking
      */
-    private GithubWebhookTracking trackWebhookRequest(String fingerprint, String deliveryId, GithubWebhookRequest request) {
-        Optional<GithubWebhookTracking> existingTracker = findTrackedRequest(fingerprint);
+    private GithubWebhookTracking trackWebhookRequest(String deliveryId, GithubWebhookRequest request) {
+        Optional<GithubWebhookTracking> existingTracker = getExistingRequestInformation(request.getInstallation().getId(),
+                request.getRepository().getFullName(), request.getPullRequest().getNumber());
         GithubWebhookTracking calculatedTracker = new GithubWebhookTracking();
-        calculatedTracker.setFingerprint(fingerprint);
         calculatedTracker.setInstallationId(request.getInstallation().getId());
         calculatedTracker.setDeliveryId(deliveryId);
         calculatedTracker.setPullRequestNumber(request.getPullRequest().getNumber());
@@ -261,25 +235,13 @@ public class GithubWebhooksResource {
                 .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(calculatedTracker));
         if (results.isEmpty()) {
             LOGGER
-                    .error("Could not save the webhook metadata, functionality will be restricted for request with fingerprint {}",
-                            fingerprint);
+                    .error("Could not save the webhook metadata, functionality will be restricted for request in repo '{}', pull request #{}",
+                            calculatedTracker.getRepositoryFullName(), calculatedTracker.getPullRequestNumber());
             return null;
         }
         return results.get(0);
     }
 
-    /**
-     * Retrieves the tracked information for a previous webhook validation request if available.
-     * 
-     * @param fingerprint the unique hash for the request that was previously tracked
-     * @return an optional containing the tracked webhook request information, or an empty optional if it can't be found.
-     */
-    private Optional<GithubWebhookTracking> findTrackedRequest(String fingerprint) {
-        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
-        params.add(GitEcaParameterNames.FINGERPRINT_RAW, fingerprint);
-        return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst();
-    }
-
     /**
      * Generate the validation request for the current GH Webhook request.
      * 
@@ -291,38 +253,4 @@ public class GithubWebhooksResource {
                 request.getPullRequest().getNumber(), request.getRepository().getHtmlUrl());
     }
 
-    private ValidationRequest generateRequest(String installationId, String repositoryFullName, int pullRequestNumber,
-            String repositoryUrl) {
-        // 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(c.getCommit().getAuthor().getEmail())
-                                        .setName(c.getCommit().getAuthor().getName())
-                                        .build())
-                                .setCommitter(GitUser
-                                        .builder()
-                                        .setMail(c.getCommit().getCommitter().getEmail())
-                                        .setName(c.getCommit().getCommitter().getName())
-                                        .build())
-                                .build())
-                        .collect(Collectors.toList()))
-                .build();
-    }
-
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
new file mode 100644
index 00000000..1bacc3b0
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
@@ -0,0 +1,141 @@
+/**
+ * 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.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.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipsefoundation.core.service.CachingService;
+import org.eclipsefoundation.git.eca.api.models.Project;
+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.ValidationRequest;
+import org.eclipsefoundation.git.eca.service.GithubApplicationService;
+import org.eclipsefoundation.git.eca.service.ProjectsService;
+import org.eclipsefoundation.git.eca.service.ValidationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkus.qute.Location;
+import io.quarkus.qute.Template;
+
+/**
+ * REST resource containing endpoints related to checking the status of validation requests.
+ * 
+ * @author Martin Lowe
+ *
+ */
+@Path("eca/status")
+public class StatusResource extends GithubAdjacentResource {
+    private static final Logger LOGGER = LoggerFactory.getLogger(StatusResource.class);
+
+    @Inject
+    CachingService cache;
+
+    @Inject
+    ProjectsService projects;
+    @Inject
+    ValidationService validation;
+    @Inject
+    GithubApplicationService ghAppService;
+
+    // Qute templates, generates UI status page
+    @Location("simple_fingerprint_ui")
+    Template statusUiTemplate;
+
+    @GET
+    @Path("{fingerprint}")
+    public Response getCommitValidation(@PathParam("fingerprint") String fingerprint) {
+        return Response.ok(validation.getHistoricValidationStatus(wrapper, fingerprint)).build();
+    }
+
+    @GET
+    @Produces(MediaType.TEXT_HTML)
+    @Path("{fingerprint}/ui")
+    public Response getCommitValidationUI(@PathParam("fingerprint") String fingerprint) {
+        List<CommitValidationStatus> statuses = validation.getHistoricValidationStatus(wrapper, fingerprint);
+        if (statuses.isEmpty()) {
+            return Response.status(404).build();
+        }
+        List<Project> ps = projects.retrieveProjectsForRepoURL(statuses.get(0).getRepoUrl(), statuses.get(0).getProvider());
+        return Response
+                .ok()
+                .entity(statusUiTemplate
+                        .data("statuses", statuses, "repoUrl", statuses.get(0).getRepoUrl(), "project", ps.isEmpty() ? null : ps.get(0))
+                        .render())
+                .build();
+    }
+
+    @GET
+    @Produces(MediaType.TEXT_HTML)
+    @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);
+        if (StringUtils.isBlank(installationId)) {
+            throw new BadRequestException("Could not find an ECA app installation for repo name: " + fullRepoName);
+        } else if (!ghAppService.doesPullRequestExist(installationId, fullRepoName, prNo)) {
+            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);
+
+        // get the commit status of commits to use
+        List<CommitValidationStatus> statuses = validation.getHistoricValidationStatusByShas(wrapper, commitShas);
+        // check if this request has been validated in the past, and if not, run validation
+        Optional<GithubWebhookTracking> tracking = getExistingRequestInformation(installationId, fullRepoName, prNo);
+        if (tracking.isEmpty() || commitShas.size() != statuses.size()) {
+            LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo);
+            // process the incoming request to ensure all commits have been validated (successful or not)
+            validation.validateIncomingRequest(vr, wrapper);
+        }
+
+        // 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
+        return Response
+                .ok()
+                .entity(statusUiTemplate
+                        .data("statuses", statuses)
+                        .data("pullRequestNumber", prNo)
+                        .data("fullRepoName", fullRepoName)
+                        .data("project", ps.isEmpty() ? null : ps.get(0))
+                        .data("repoUrl", repoUrl)
+                        .data("installationId", installationId)
+                        .render())
+                .build();
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java
new file mode 100644
index 00000000..204103db
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java
@@ -0,0 +1,36 @@
+/**
+ * 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.service;
+
+/**
+ * @author martin
+ *
+ */
+public interface GithubApplicationService {
+
+    /**
+     * Retrieves the installation ID for the ECA app on the given repo if it exists.
+     * 
+     * @param repoFullName the full repo name to retrieve an installation ID for. E.g. eclipse/jetty
+     * @return the numeric installation ID if it exists, or null
+     */
+    String getInstallationForRepo(String repoFullName);
+
+    /**
+     * 
+     * @param installationId
+     * @param repoFullName
+     * @param pullRequest
+     * @return
+     */
+    boolean doesPullRequestExist(String installationId, String repoFullName, Integer pullRequest);
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java
index 0110228c..1f441ca0 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java
@@ -50,6 +50,15 @@ public interface ValidationService {
      */
     public List<CommitValidationStatus> getHistoricValidationStatus(RequestWrapper wrapper, String fingerprint);
 
+    /**
+     * Retrieves a set of validation status objects given the target shas.
+     * 
+     * @param wrapper current request wrapper object
+     * @param shas list of shas to use when fetching historic commit statuses
+     * @return the list of historic validation status objects, or an empty list.
+     */
+    public List<CommitValidationStatus> getHistoricValidationStatusByShas(RequestWrapper wrapper, List<String> shas);
+
     /**
      * Retrieves a set of commit validation status objects given a validation request and target project.
      * 
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
new file mode 100644
index 00000000..c836d51f
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.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.service.impl;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.PostConstruct;
+import javax.enterprise.context.ApplicationScoped;
+import javax.inject.Inject;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.context.ManagedExecutor;
+import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.eclipsefoundation.core.service.APIMiddleware;
+import org.eclipsefoundation.core.service.CachingService;
+import org.eclipsefoundation.git.eca.api.GithubAPI;
+import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallation;
+import org.eclipsefoundation.git.eca.api.models.GithubInstallationRepositoriesResponse;
+import org.eclipsefoundation.git.eca.helper.JwtHelper;
+import org.eclipsefoundation.git.eca.service.GithubApplicationService;
+import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+
+/**
+ * Default caching implementation of the GH app service. This uses a loading cache to keep installation info highly
+ * available to reduce latency in calls.
+ * 
+ * @author Martin Lowe
+ *
+ */
+@ApplicationScoped
+public class DefaultGithubApplicationService implements GithubApplicationService {
+    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultGithubApplicationService.class);
+
+    @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
+    String apiVersion;
+
+    @RestClient
+    GithubAPI gh;
+
+    @Inject
+    JwtHelper jwt;
+    @Inject
+    CachingService cache;
+    @Inject
+    APIMiddleware middle;
+
+    @Inject
+    ManagedExecutor exec;
+
+    private AsyncLoadingCache<String, MultivaluedMap<String, String>> installationRepositoriesCache;
+
+    @PostConstruct
+    void init() {
+        this.installationRepositoriesCache = Caffeine
+                .newBuilder()
+                .executor(exec)
+                .maximumSize(10)
+                .refreshAfterWrite(Duration.ofMinutes(60))
+                .buildAsync(k -> loadInstallationRepositories());
+        // do initial map population
+        getAllInstallRepos();
+    }
+
+    @Override
+    public String getInstallationForRepo(String repoFullName) {
+        MultivaluedMap<String, String> map = getAllInstallRepos();
+        return map.keySet().stream().filter(k -> map.get(k).contains(repoFullName)).findFirst().orElse(null);
+    }
+
+    private MultivaluedMap<String, String> getAllInstallRepos() {
+        try {
+            return this.installationRepositoriesCache.get("all").get(10L, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            LOGGER.error("Thread interrupted while building repository cache, no entries will be available for current call");
+            Thread.currentThread().interrupt();
+        } catch (Exception e) {
+            // rewrap exception and throw
+            throw new RuntimeException(e);
+        }
+        return new MultivaluedMapImpl<>();
+    }
+
+    @Override
+    public boolean doesPullRequestExist(String installationId, String repoFullName, Integer pullRequest) {
+        // get a cached response to check if the PR exists (indicated by a 200 response)
+        Optional<Response> prResponse = cache
+                .get(repoFullName, new MultivaluedMapImpl<>(), Response.class,
+                        () -> gh.getPullRequest(jwt.getGhBearerString(installationId), apiVersion, repoFullName, pullRequest));
+        return prResponse.isPresent() && prResponse.get().getStatus() == 200;
+    }
+
+    /**
+     * Retrieves a fresh copy of installation repositories, mapped by installation ID to associated full repo names.
+     * 
+     * @return a multivalued map relating installation IDs to associated full repo names.
+     */
+    private MultivaluedMap<String, String> loadInstallationRepositories() {
+        // create map early for potential empty returns
+        MultivaluedMapImpl<String, String> out = new MultivaluedMapImpl<>();
+        // get JWT bearer and then get all associated installations of current app
+        String auth = "Bearer " + jwt.generateJwt();
+        List<GithubApplicationInstallation> installations = middle
+                .getAll(i -> gh.getInstallations(i, auth), GithubApplicationInstallation.class);
+        // check that there are installations
+        if (installations.isEmpty()) {
+            LOGGER.warn("Did not find any installations for the currently configured Github application");
+            return out;
+        }
+
+        // from installations, get the assoc. repos and grab their full repo name and collect them
+        installations
+                .stream()
+                .forEach(installation -> middle
+                        .getAll(i -> gh.getInstallationRepositories(i, jwt.getGhBearerString(Integer.toString(installation.getId()))),
+                                GithubInstallationRepositoriesResponse.class)
+                        .stream()
+                        .forEach(installRepo -> installRepo
+                                .getRepositories()
+                                .stream()
+                                .forEach(r -> out.add(Integer.toString(installation.getId()), r.getFullName()))));
+        return out;
+    }
+
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
index 76d319e7..420ffed7 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
@@ -136,6 +136,19 @@ public class DefaultValidationService implements ValidationService {
         return dao.get(q).stream().map(statusGrouping -> statusGrouping.getCompositeId().getCommit()).collect(Collectors.toList());
     }
 
+    @Override
+    public List<CommitValidationStatus> getHistoricValidationStatusByShas(RequestWrapper wrapper, List<String> shas) {
+        if (shas == null || shas.isEmpty()) {
+            return Collections.emptyList();
+        }
+        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
+        params.put(GitEcaParameterNames.SHAS_RAW, shas);
+        RDBMSQuery<CommitValidationStatus> q = new RDBMSQuery<>(wrapper, filters.get(CommitValidationStatus.class), params);
+        // set use limit to false to collect all data in one request
+        q.setUseLimit(false);
+        return dao.get(q);
+    }
+
     @Override
     public List<CommitValidationStatus> getRequestCommitValidationStatus(RequestWrapper wrapper, ValidationRequest req, String projectId) {
         RDBMSQuery<CommitValidationStatus> q = new RDBMSQuery<>(wrapper, filters.get(CommitValidationStatus.class),
@@ -151,6 +164,15 @@ public class DefaultValidationService implements ValidationService {
         // iterate over commit responses, and update statuses in DB
         List<CommitValidationStatus> updatedStatuses = new ArrayList<>();
         r.getCommits().entrySet().stream().filter(e -> !ValidationResponse.NIL_HASH_PLACEHOLDER.equalsIgnoreCase(e.getKey())).forEach(e -> {
+            // get the commit for current status
+            Optional<Commit> commit = req.getCommits().stream().filter(c -> e.getKey().equals(c.getHash())).findFirst();
+            if (commit.isEmpty()) {
+                // this should always have a match (response commits are built from request commits)
+                LOGGER.error("Could not find request commit associated with commit messages for commit hash '{}'", e.getKey());
+                return;
+            }
+            Commit c = commit.get();
+
             // update the status if present, otherwise make new one.
             Optional<CommitValidationStatus> status = statuses.stream().filter(s -> e.getKey().equals(s.getCommitHash())).findFirst();
             CommitValidationStatus base;
@@ -160,6 +182,7 @@ public class DefaultValidationService implements ValidationService {
                 base = new CommitValidationStatus();
                 base.setProject(CommitHelper.getProjectId(p));
                 base.setCommitHash(e.getKey());
+                base.setUserMail(c.getAuthor().getMail());
                 base.setProvider(req.getProvider());
                 base.setRepoUrl(req.getRepoUrl().toString());
                 base.setCreationDate(DateTimeHelper.now());
@@ -167,13 +190,7 @@ public class DefaultValidationService implements ValidationService {
             }
             base.setLastModified(DateTimeHelper.now());
             updatedStatuses.add(base);
-            // get the commit for current status
-            Optional<Commit> commit = req.getCommits().stream().filter(c -> e.getKey().equals(c.getHash())).findFirst();
-            if (commit.isEmpty()) {
-                LOGGER.error("Could not find request commit associated with commit messages for commit hash '{}'", e.getKey());
-                return;
-            }
-            Commit c = commit.get();
+            
             // if there are errors, update validation messages
             if (!e.getValue().getErrors().isEmpty() || (base.getErrors() != null && !base.getErrors().isEmpty())) {
                 // generate new errors, looking for errors not found in current list
@@ -234,7 +251,7 @@ public class DefaultValidationService implements ValidationService {
         response.addMessage(c.getHash(), String.format("Authored by: %1$s <%2$s>", author.getName(), author.getMail()));
 
         // skip processing if a merge commit
-        if (c.getParents().size() > 1) {
+        if (c.getParents() != null && c.getParents().size() > 1) {
             response
                     .addMessage(c.getHash(),
                             String.format("Commit '%1$s' has multiple parents, merge commit detected, passing", c.getHash()));
diff --git a/src/main/resources/templates/simple_fingerprint_ui.html b/src/main/resources/templates/simple_fingerprint_ui.html
index 69af38b8..f0eb2bea 100644
--- a/src/main/resources/templates/simple_fingerprint_ui.html
+++ b/src/main/resources/templates/simple_fingerprint_ui.html
@@ -140,9 +140,9 @@
         </div>
       </div>
       {/if}
-      {#if statuses.0.provider == ProviderType:GITHUB}
+      {#if statuses.0.provider == ProviderType:GITHUB && pullRequestNumber != null && fullRepoName != null}
       <div>
-        <form id="git-eca-hook-revalidation" data-request-id="{fingerprint}">
+        <form id="git-eca-hook-revalidation" data-request-number="{pullRequestNumber}" data-request-repo="{fullRepoName}" data-request-installation="{installationId}">
           <div class="captcha">
             <div class="h-captcha" data-sitekey="{config:['eclipse.hcaptcha.sitekey']}"></div>
           </div>
@@ -266,9 +266,15 @@
         const $submitButton = $form.find('button');
         // disable the button so that requests won't be spammed
         $submitButton.attr("disabled", "disabled");
+        // set up params to set up for revalidation
+        let params = $.param({
+            repo_full_name: $form.data('request-repo'),
+            pull_request_number: $form.data('request-number'),
+            installation_id: $form.data('request-installation')
+        });
         // use ajax to revalidate the commit with GH
         $.ajax({
-          url: `/git/webhooks/github/revalidate/${$form.data('request-id')}`,
+          url: `/git/webhooks/github/revalidate?${params}`,
           data: $form.serialize(),
           type: 'POST',
           success: function (data) {
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/CommitHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/CommitHelperTest.java
index bb24b9eb..982eb6ae 100644
--- a/src/test/java/org/eclipsefoundation/git/eca/helper/CommitHelperTest.java
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/CommitHelperTest.java
@@ -58,13 +58,6 @@ class CommitHelperTest {
         Assertions.assertFalse(CommitHelper.validateCommit(null), "Expected null commit to fail validation");
     }
 
-    @Test
-    void validateCommitNoAuthor() {
-        baseCommit.setAuthor(null);
-        Assertions.assertFalse(CommitHelper.validateCommit(baseCommit.build()),
-                "Expected basic commit to fail validation w/ no author");
-    }
-
     @Test
     void validateCommitNoAuthorMail() {
         baseCommit.setAuthor(GitUser.builder().setName("Some Name").build());
@@ -72,13 +65,6 @@ class CommitHelperTest {
                 "Expected basic commit to fail validation w/ no author mail address");
     }
 
-    @Test
-    void validateCommitNoCommitter() {
-        baseCommit.setCommitter(null);
-        Assertions.assertFalse(CommitHelper.validateCommit(baseCommit.build()),
-                "Expected basic commit to fail validation w/ no committer");
-    }
-
     @Test
     void validateCommitNoCommitterMail() {
         baseCommit.setCommitter(GitUser.builder().setName("Some Name").build());
@@ -86,13 +72,6 @@ class CommitHelperTest {
                 "Expected basic commit to fail validation w/ no committer mail address");
     }
 
-    @Test
-    void validateCommitNoHash() {
-        baseCommit.setHash(null);
-        Assertions.assertFalse(CommitHelper.validateCommit(baseCommit.build()),
-                "Expected basic commit to fail validation w/ no commit hash");
-    }
-
     @Test
     void validateCommitNoBody() {
         baseCommit.setBody(null);
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 1f941ac3..ff4c33f7 100644
--- a/src/test/resources/database/default/V1.0.0__default.sql
+++ b/src/test/resources/database/default/V1.0.0__default.sql
@@ -7,6 +7,7 @@ CREATE TABLE CommitValidationStatus (
   provider varchar(100) NOT NULL,
   estimatedLoc int DEFAULT 0,
   repoUrl varchar(255) NOT NULL,
+  userMail varchar(255) DEFAULT NULL,
   PRIMARY KEY (id)
 );
 INSERT INTO CommitValidationStatus(commitHash,project,lastModified,creationDate,provider, repoUrl) VALUES('123456789', 'sample.proj', NOW(), NOW(),'GITHUB','http://www.github.com/eclipsefdn/sample');
-- 
GitLab


From 44869ea5492cd1d440a59acd0ba0583b6bb9e696 Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Wed, 5 Apr 2023 10:07:43 -0400
Subject: [PATCH 2/6] Update to use api-common 0.7.3, includes API middleware
 fixes

---
 pom.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pom.xml b/pom.xml
index 85c87b1f..98925c04 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
   <artifactId>git-eca</artifactId>
   <version>1.1.0</version>
   <properties>
-    <eclipse-api-version>0.7.3-SNAPSHOT</eclipse-api-version>
+    <eclipse-api-version>0.7.3</eclipse-api-version>
     <compiler-plugin.version>3.8.1</compiler-plugin.version>
     <maven.compiler.parameters>true</maven.compiler.parameters>
     <maven.compiler.source>11</maven.compiler.source>
-- 
GitLab


From 63f625e5e4c850a4a411284e1bf269c008af18e2 Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Wed, 5 Apr 2023 10:50:24 -0400
Subject: [PATCH 3/6] Add trace logging to GH app service for more context data

---
 .../git/eca/service/impl/DefaultGithubApplicationService.java  | 3 +++
 src/main/resources/application.properties                      | 2 ++
 2 files changed, 5 insertions(+)

diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
index c836d51f..9d223c51 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
@@ -125,6 +125,8 @@ public class DefaultGithubApplicationService implements GithubApplicationService
             LOGGER.warn("Did not find any installations for the currently configured Github application");
             return out;
         }
+        // trace log the installations for more context
+        LOGGER.trace("Found the following installations: {}", installations);
 
         // from installations, get the assoc. repos and grab their full repo name and collect them
         installations
@@ -137,6 +139,7 @@ public class DefaultGithubApplicationService implements GithubApplicationService
                                 .getRepositories()
                                 .stream()
                                 .forEach(r -> out.add(Integer.toString(installation.getId()), r.getFullName()))));
+        LOGGER.trace("Final results for generating installation mapping: {}", out);
         return out;
     }
 
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index aa6323da..d53dbd51 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -51,3 +51,5 @@ eclipse.mail.allowlist=noreply@github.com,49699333+dependabot[bot]@users.noreply
 %dev.eclipse.scheduled.private-project.enabled=false
 
 eclipse.system-hook.pool-size=5
+quarkus.log.category."org.eclipsefoundation.git.eca.service".min-level=TRACE
+quarkus.log.category."org.eclipsefoundation.git.eca.service".level=TRACE
-- 
GitLab


From a0acfb7f1b9434a9ff30d9a053ad59efbc44e3fc Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Thu, 6 Apr 2023 10:47:59 -0400
Subject: [PATCH 4/6] Add call to track GH webhook on new validation if not
 set, clean up

Clean up of validation and status resources to properly move all calls
for status checks to the new resource. Additionally, call added when
calling for status of untracked GH PR to add some basic tracking to
prevent unneeded future calls.
---
 .../git/eca/resource/StatusResource.java      | 47 ++++++++++++++++++-
 .../git/eca/resource/ValidationResource.java  | 34 --------------
 src/main/resources/application.properties     |  2 -
 3 files changed, 46 insertions(+), 37 deletions(-)

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 1bacc3b0..2759c755 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
@@ -11,6 +11,7 @@
  */
 package org.eclipsefoundation.git.eca.resource;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -26,6 +27,7 @@ import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 
 import org.apache.commons.lang3.StringUtils;
+import org.eclipsefoundation.core.helper.DateTimeHelper;
 import org.eclipsefoundation.core.service.CachingService;
 import org.eclipsefoundation.git.eca.api.models.Project;
 import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
@@ -35,6 +37,7 @@ import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.service.GithubApplicationService;
 import org.eclipsefoundation.git.eca.service.ProjectsService;
 import org.eclipsefoundation.git.eca.service.ValidationService;
+import org.eclipsefoundation.persistence.model.RDBMSQuery;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -83,7 +86,12 @@ public class StatusResource extends GithubAdjacentResource {
         return Response
                 .ok()
                 .entity(statusUiTemplate
-                        .data("statuses", statuses, "repoUrl", statuses.get(0).getRepoUrl(), "project", ps.isEmpty() ? null : ps.get(0))
+                        .data("statuses", statuses)
+                        .data("pullRequestNumber", null)
+                        .data("fullRepoName", null)
+                        .data("project", ps.isEmpty() ? null : ps.get(0))
+                        .data("repoUrl", statuses.get(0).getRepoUrl())
+                        .data("installationId", null)
                         .render())
                 .build();
     }
@@ -121,6 +129,10 @@ public class StatusResource extends GithubAdjacentResource {
             LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo);
             // process the incoming request to ensure all commits have been validated (successful or not)
             validation.validateIncomingRequest(vr, wrapper);
+            // update the tracking after revalidating the request
+            updateGithubTrackingIfMissing(tracking, installationId, fullRepoName, prNo);
+            // call to retrieve the statuses once again since they will have changed at this point
+            statuses= validation.getHistoricValidationStatusByShas(wrapper, commitShas);
         }
 
         // get projects for use in status UI
@@ -138,4 +150,37 @@ public class StatusResource extends GithubAdjacentResource {
                         .render())
                 .build();
     }
+
+    /**
+     * 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 installationId the ECA app installation ID for the current request
+     * @param fullRepoName the full repo name for the validation request
+     * @param pullRequestNumber the pull request number of the commit set that is being validated
+     */
+    private void updateGithubTrackingIfMissing(Optional<GithubWebhookTracking> tracking, String installationId, String fullRepoName,
+            int pullRequestNumber) {
+        // if there is no tracking present, create the missing tracking and persist it
+        if (tracking.isEmpty()) {
+            GithubWebhookTracking newTracking = new GithubWebhookTracking();
+            newTracking.setDeliveryId("");
+            newTracking.setFingerprint("");
+            newTracking.setHeadSha("");
+            newTracking.setInstallationId(installationId);
+            newTracking.setLastUpdated(DateTimeHelper.now());
+            newTracking.setPullRequestNumber(pullRequestNumber);
+            newTracking.setRepositoryFullName(fullRepoName);
+
+            // save the data, and log on its success or failure
+            List<GithubWebhookTracking> savedTracking = dao
+                    .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(newTracking));
+            if (!savedTracking.isEmpty()) {
+                LOGGER.debug("Created new GH tracking record for request to validate {}#{}", fullRepoName, pullRequestNumber);
+            } else {
+                LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", fullRepoName, pullRequestNumber);
+            }
+        }
+    }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
index 98b58b95..66e68922 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
@@ -22,7 +22,6 @@ import javax.ws.rs.Consumes;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
-import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
@@ -31,8 +30,6 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.eclipsefoundation.core.model.RequestWrapper;
 import org.eclipsefoundation.core.service.CachingService;
 import org.eclipsefoundation.git.eca.api.models.EclipseUser;
-import org.eclipsefoundation.git.eca.api.models.Project;
-import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
 import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.model.ValidationResponse;
 import org.eclipsefoundation.git.eca.namespace.APIStatusCode;
@@ -44,9 +41,6 @@ import org.jboss.resteasy.annotations.jaxrs.QueryParam;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import io.quarkus.qute.Location;
-import io.quarkus.qute.Template;
-
 /**
  * ECA validation endpoint for Git commits. Will use information from the bots, projects, and accounts API to validate
  * commits passed to this endpoint. Should be as system agnostic as possible to allow for any service to request
@@ -82,10 +76,6 @@ public class ValidationResource {
     @Inject
     InterestGroupService ig;
 
-    // Qute templates, generates email bodies
-    @Location("simple_fingerprint_ui")
-    Template membershipTemplateWeb;
-
     /**
      * Consuming a JSON request, this method will validate all passed commits, using the repo URL and the repository
      * provider. These commits will be validated to ensure that all users are covered either by an ECA, or are committers on
@@ -112,30 +102,6 @@ public class ValidationResource {
         }
     }
 
-    @GET
-    @Path("status/{fingerprint}")
-    public Response getCommitValidation(@PathParam("fingerprint") String fingerprint) {
-        return Response.ok(validation.getHistoricValidationStatus(wrapper, fingerprint)).build();
-    }
-
-    @GET
-    @Produces(MediaType.TEXT_HTML)
-    @Path("status/{fingerprint}/ui")
-    public Response getCommitValidationUI(@PathParam("fingerprint") String fingerprint) {
-        List<CommitValidationStatus> statuses = validation.getHistoricValidationStatus(wrapper, fingerprint);
-        if (statuses.isEmpty()) {
-            return Response.status(404).build();
-        }
-        List<Project> ps = projects.retrieveProjectsForRepoURL(statuses.get(0).getRepoUrl(), statuses.get(0).getProvider());
-        return Response
-                .ok()
-                .entity(membershipTemplateWeb
-                        .data("statuses", statuses, "repoUrl", statuses.get(0).getRepoUrl(), "project", ps.isEmpty() ? null : ps.get(0),
-                                "fingerprint", fingerprint)
-                        .render())
-                .build();
-    }
-
     @GET
     @Path("/lookup")
     public Response getUserStatus(@QueryParam("email") String email) {
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index d53dbd51..aa6323da 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -51,5 +51,3 @@ eclipse.mail.allowlist=noreply@github.com,49699333+dependabot[bot]@users.noreply
 %dev.eclipse.scheduled.private-project.enabled=false
 
 eclipse.system-hook.pool-size=5
-quarkus.log.category."org.eclipsefoundation.git.eca.service".min-level=TRACE
-quarkus.log.category."org.eclipsefoundation.git.eca.service".level=TRACE
-- 
GitLab


From 3d3d98ac3a6f6efc38a042931fd871751669f337 Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Tue, 11 Apr 2023 09:45:51 -0400
Subject: [PATCH 5/6] Code clean up for GH webhook code, as well as tightening
 of logic

---
 .../git/eca/api/GithubAPI.java                |  4 +-
 .../eca/api/models/GithubWebhookRequest.java  |  4 +
 .../eca/resource/GithubAdjacentResource.java  | 74 +++++++++++++++++++
 .../eca/resource/GithubWebhooksResource.java  | 74 -------------------
 .../git/eca/resource/StatusResource.java      | 42 +++++++----
 .../git/eca/resource/ValidationResource.java  |  2 -
 .../eca/service/GithubApplicationService.java | 19 +++--
 .../impl/DefaultGithubApplicationService.java | 10 +--
 .../impl/DefaultValidationService.java        | 28 ++++++-
 9 files changed, 150 insertions(+), 107 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 fb9cc868..cbe64f49 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -24,6 +24,7 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
 import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters;
 import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.jboss.resteasy.util.HttpHeaderNames;
 
 /**
@@ -38,7 +39,7 @@ public interface GithubAPI {
 
     @GET
     @Path("repos/{repoFull}/pulls/{pullNumber}")
-    public Response getPullRequest(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
+    public PullRequest getPullRequest(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("repoFull") String repoFull,
             @PathParam("pullNumber") int pullNumber);
 
@@ -58,6 +59,7 @@ public interface GithubAPI {
      * 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
      */
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java
index 90d8b50b..5bf0d7db 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java
@@ -104,6 +104,8 @@ public abstract class GithubWebhookRequest {
     @JsonDeserialize(builder = AutoValue_GithubWebhookRequest_PullRequest.Builder.class)
     public abstract static class PullRequest {
 
+        public abstract String getState();
+
         public abstract Integer getNumber();
 
         public abstract PullRequestHead getHead();
@@ -115,6 +117,8 @@ public abstract class GithubWebhookRequest {
         @AutoValue.Builder
         @JsonPOJOBuilder(withPrefix = "set")
         public abstract static class Builder {
+            public abstract Builder setState(String state);
+
             public abstract Builder setNumber(Integer number);
 
             public abstract Builder setHead(PullRequestHead head);
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 153e062f..8393c154 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubAdjacentResource.java
@@ -28,13 +28,18 @@ 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.GithubCommitStatusRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 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.ValidationService;
 import org.eclipsefoundation.persistence.dao.PersistenceDao;
 import org.eclipsefoundation.persistence.model.RDBMSQuery;
 import org.eclipsefoundation.persistence.service.FilterService;
@@ -51,6 +56,12 @@ 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.webhooks.github.context")
+    String context;
+    @ConfigProperty(name = "eclipse.webhooks.github.server-target")
+    String serverTarget;
     @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
     String apiVersion;
 
@@ -63,6 +74,9 @@ public abstract class GithubAdjacentResource {
     @Inject
     FilterService filters;
 
+    @Inject
+    ValidationService validationService;
+
     @Inject
     RequestWrapper wrapper;
 
@@ -130,6 +144,66 @@ public abstract class GithubAdjacentResource {
         return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst();
     }
 
+    /**
+     * 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 = validationService.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(serverTarget + "/git/eca/status/gh" + request.getRepository().getFullName() + '/'
+                                        + request.getPullRequest().getNumber())
+                                .setContext(context)
+                                .build());
+    }
+
     /**
      * Validates required fields for processing requests.
      * 
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 1d6c927e..feb89850 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
@@ -25,20 +25,15 @@ import javax.ws.rs.Path;
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Response;
 
-import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.eclipsefoundation.core.helper.DateTimeHelper;
 import org.eclipsefoundation.core.service.CachingService;
-import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
 import org.eclipsefoundation.git.eca.helper.CaptchaHelper;
 import org.eclipsefoundation.git.eca.model.RevalidationResponse;
 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.HCaptchaErrorCodes;
-import org.eclipsefoundation.git.eca.service.ValidationService;
 import org.eclipsefoundation.persistence.model.RDBMSQuery;
 import org.jboss.resteasy.annotations.jaxrs.HeaderParam;
 import org.slf4j.Logger;
@@ -54,15 +49,6 @@ import org.slf4j.LoggerFactory;
 public class GithubWebhooksResource extends GithubAdjacentResource {
     private static final Logger LOGGER = LoggerFactory.getLogger(GithubWebhooksResource.class);
 
-    private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}";
-
-    @ConfigProperty(name = "eclipse.webhooks.github.context")
-    String context;
-    @ConfigProperty(name = "eclipse.webhooks.github.server-target")
-    String serverTarget;
-
-    @Inject
-    ValidationService validationService;
     @Inject
     CaptchaHelper captchaHelper;
 
@@ -148,66 +134,6 @@ public class GithubWebhooksResource extends GithubAdjacentResource {
         return Response.ok(RevalidationResponse.builder().setLocation(URI.create(sb.toString())).build()).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.
-     */
-    private 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 = validationService.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(serverTarget + "/git/eca/status/gh" + request.getRepository().getFullName() + '/'
-                                        + request.getPullRequest().getNumber())
-                                .setContext(context)
-                                .build());
-    }
-
     /**
      * Create a new entry in the webhook request tracking table to facilitate revalidation requests from the ECA system
      * rather than just from Github.
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 2759c755..f71fdb02 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/StatusResource.java
@@ -23,12 +23,15 @@ 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.core.helper.DateTimeHelper;
 import org.eclipsefoundation.core.service.CachingService;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.api.models.Project;
 import org.eclipsefoundation.git.eca.dto.CommitValidationStatus;
 import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
@@ -104,10 +107,11 @@ public class StatusResource extends GithubAdjacentResource {
         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 (!ghAppService.doesPullRequestExist(installationId, fullRepoName, prNo)) {
-            throw new NotFoundException(String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName));
+        } else if (prResponse.isEmpty() || !"open".equalsIgnoreCase(prResponse.get().getState())) {
+            throw new NotFoundException(String.format("Could not find open PR '%d' in repo name '%s'", prNo, fullRepoName));
         }
 
         // prepare the request for consumption
@@ -127,12 +131,15 @@ public class StatusResource extends GithubAdjacentResource {
         Optional<GithubWebhookTracking> tracking = getExistingRequestInformation(installationId, fullRepoName, prNo);
         if (tracking.isEmpty() || commitShas.size() != statuses.size()) {
             LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo);
-            // process the incoming request to ensure all commits have been validated (successful or not)
-            validation.validateIncomingRequest(vr, wrapper);
-            // update the tracking after revalidating the request
-            updateGithubTrackingIfMissing(tracking, installationId, fullRepoName, prNo);
+            // update the tracking before revalidating the request
+            GithubWebhookTracking updatedTracking = updateGithubTrackingIfMissing(tracking, prResponse.get(), installationId, fullRepoName);
+            if (updatedTracking == null) {
+                throw new ServerErrorException("Error while attempting to revalidate request, try again later.", 500);
+            }
+            // 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);
+            statuses = validation.getHistoricValidationStatusByShas(wrapper, commitShas);
         }
 
         // get projects for use in status UI
@@ -157,30 +164,33 @@ public class StatusResource extends GithubAdjacentResource {
      * 
      * @param tracking the optional tracking entry for the current request
      * @param installationId the ECA app installation ID for the current request
+     * @param request the pull request that is being validated
      * @param fullRepoName the full repo name for the validation request
-     * @param pullRequestNumber the pull request number of the commit set that is being validated
      */
-    private void updateGithubTrackingIfMissing(Optional<GithubWebhookTracking> tracking, String installationId, String fullRepoName,
-            int pullRequestNumber) {
+    private GithubWebhookTracking updateGithubTrackingIfMissing(Optional<GithubWebhookTracking> tracking, PullRequest request,
+            String installationId, String fullRepoName) {
         // if there is no tracking present, create the missing tracking and persist it
         if (tracking.isEmpty()) {
             GithubWebhookTracking newTracking = new GithubWebhookTracking();
             newTracking.setDeliveryId("");
             newTracking.setFingerprint("");
-            newTracking.setHeadSha("");
+            newTracking.setHeadSha(request.getHead().getSha());
             newTracking.setInstallationId(installationId);
             newTracking.setLastUpdated(DateTimeHelper.now());
-            newTracking.setPullRequestNumber(pullRequestNumber);
+            newTracking.setPullRequestNumber(request.getNumber());
             newTracking.setRepositoryFullName(fullRepoName);
 
             // save the data, and log on its success or failure
             List<GithubWebhookTracking> savedTracking = dao
                     .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(newTracking));
-            if (!savedTracking.isEmpty()) {
-                LOGGER.debug("Created new GH tracking record for request to validate {}#{}", fullRepoName, pullRequestNumber);
-            } else {
-                LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", fullRepoName, pullRequestNumber);
+            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);
         }
+        return tracking.get();
     }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
index 66e68922..90df077e 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
@@ -12,7 +12,6 @@
 **********************************************************************/
 package org.eclipsefoundation.git.eca.resource;
 
-import java.net.MalformedURLException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
@@ -85,7 +84,6 @@ public class ValidationResource {
      * @param req the request containing basic data plus the commits to be validated
      * @return a web response indicating success or failure for each commit, along with standard messages that may be used
      * to give users context on failure.
-     * @throws MalformedURLException
      */
     @POST
     public Response validate(ValidationRequest req) {
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java
index 204103db..3a36f46c 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/GithubApplicationService.java
@@ -11,8 +11,14 @@
  */
 package org.eclipsefoundation.git.eca.service;
 
+import java.util.Optional;
+
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
+
 /**
- * @author martin
+ * Service for interacting with the Github API with caching for performance.
+ * 
+ * @author Martin Lowe
  *
  */
 public interface GithubApplicationService {
@@ -26,11 +32,12 @@ public interface GithubApplicationService {
     String getInstallationForRepo(String repoFullName);
 
     /**
+     * Retrieves a pull request given the repo, pull request, and associated installation to action the fetch.
      * 
-     * @param installationId
-     * @param repoFullName
-     * @param pullRequest
-     * @return
+     * @param installationId installation ID to use when creating access tokens to query GH API
+     * @param repoFullName the full repo name, where the org and repo name are joined by a slash, e.g. eclipse/jetty
+     * @param pullRequest the pull request numeric ID
+     * @return the pull request if it exists, otherwise empty
      */
-    boolean doesPullRequestExist(String installationId, String repoFullName, Integer pullRequest);
+    Optional<PullRequest> getPullRequest(String installationId, String repoFullName, Integer pullRequest);
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
index 9d223c51..9027ca48 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultGithubApplicationService.java
@@ -20,7 +20,6 @@ import javax.annotation.PostConstruct;
 import javax.enterprise.context.ApplicationScoped;
 import javax.inject.Inject;
 import javax.ws.rs.core.MultivaluedMap;
-import javax.ws.rs.core.Response;
 
 import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.eclipse.microprofile.context.ManagedExecutor;
@@ -30,6 +29,7 @@ import org.eclipsefoundation.core.service.CachingService;
 import org.eclipsefoundation.git.eca.api.GithubAPI;
 import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallation;
 import org.eclipsefoundation.git.eca.api.models.GithubInstallationRepositoriesResponse;
+import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest;
 import org.eclipsefoundation.git.eca.helper.JwtHelper;
 import org.eclipsefoundation.git.eca.service.GithubApplicationService;
 import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
@@ -100,12 +100,10 @@ public class DefaultGithubApplicationService implements GithubApplicationService
     }
 
     @Override
-    public boolean doesPullRequestExist(String installationId, String repoFullName, Integer pullRequest) {
-        // get a cached response to check if the PR exists (indicated by a 200 response)
-        Optional<Response> prResponse = cache
-                .get(repoFullName, new MultivaluedMapImpl<>(), Response.class,
+    public Optional<PullRequest> getPullRequest(String installationId, String repoFullName, Integer pullRequest) {
+        return cache
+                .get(repoFullName, new MultivaluedMapImpl<>(), PullRequest.class,
                         () -> gh.getPullRequest(jwt.getGhBearerString(installationId), apiVersion, repoFullName, pullRequest));
-        return prResponse.isPresent() && prResponse.get().getStatus() == 200;
     }
 
     /**
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
index 420ffed7..a9fa1872 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
@@ -15,6 +15,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 import javax.enterprise.context.ApplicationScoped;
@@ -112,7 +113,7 @@ public class DefaultValidationService implements ValidationService {
             // get the current status if present
             Optional<CommitValidationStatus> status = statuses.stream().filter(s -> s.getCommitHash().equals(c.getHash())).findFirst();
             // skip the commit validation if already passed
-            if (status.isPresent() && status.get().getErrors().isEmpty()) {
+            if (isValidationStatusCurrentAndValid(status, c)) {
                 r.addMessage(c.getHash(), "Commit was previously validated, skipping processing", APIStatusCode.SUCCESS_SKIPPED);
                 return;
             }
@@ -190,7 +191,7 @@ public class DefaultValidationService implements ValidationService {
             }
             base.setLastModified(DateTimeHelper.now());
             updatedStatuses.add(base);
-            
+
             // if there are errors, update validation messages
             if (!e.getValue().getErrors().isEmpty() || (base.getErrors() != null && !base.getErrors().isEmpty())) {
                 // generate new errors, looking for errors not found in current list
@@ -451,4 +452,27 @@ public class DefaultValidationService implements ValidationService {
         }
         return null;
     }
+
+    /**
+     * <p>
+     * Checks the following to determine whether a commit has already been validated:
+     * </p>
+     * <ul>
+     * <li>The validation status exists
+     * <li>Validation status has no present errors
+     * <li>Modification date is either unset or matches when set
+     * <li>User mail is set and matches (ignores case)
+     * </ul>
+     * 
+     * If any of these checks fail, then the commit should be revalidated.
+     * 
+     * @param status the current commits validation status if it exists
+     * @param c the commit that is being validated
+     * @return true if the commit does not need to be (re)validated, false otherwise.
+     */
+    private boolean isValidationStatusCurrentAndValid(Optional<CommitValidationStatus> status, Commit c) {
+        return status.isPresent() && status.get().getErrors().isEmpty() && c.getAuthor() != null
+                && status.get().getUserMail().equalsIgnoreCase(c.getAuthor().getMail())
+                && (c.getLastModificationDate() == null || status.get().getLastModified().equals(c.getLastModificationDate()));
+    }
 }
-- 
GitLab


From c60f0067ba2d1e69f4af6146fb95787628c64459 Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Tue, 11 Apr 2023 11:36:50 -0400
Subject: [PATCH 6/6] Update docs for some of the classes around the GH webhook

---
 .../git/eca/api/GithubAPI.java                | 44 +++++++++++++++++++
 .../git/eca/api/models/GithubAccessToken.java |  8 +++-
 .../models/GithubApplicationInstallation.java |  2 +-
 3 files changed, 51 insertions(+), 3 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 cbe64f49..292d6f3c 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -37,18 +37,47 @@ import org.jboss.resteasy.util.HttpHeaderNames;
 @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/{repoFull}/pulls/{pullNumber}")
     public PullRequest getPullRequest(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("repoFull") String repoFull,
             @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 repoFull the full 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/{repoFull}/pulls/{pullNumber}/commits")
     public Response getCommits(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("repoFull") String repoFull,
             @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 repoFull the full 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/{repoFull}/statuses/{prHeadSha}")
     public Response updateStatus(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
@@ -67,11 +96,26 @@ public interface GithubAPI {
     @Path("app/installations")
     public Response getInstallations(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaderNames.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(HttpHeaderNames.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,
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubAccessToken.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubAccessToken.java
index 437e4a6c..887e1cb7 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubAccessToken.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubAccessToken.java
@@ -18,7 +18,9 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.google.auto.value.AutoValue;
 
 /**
- * @author martin
+ * Contains the information about the Github application access tokens
+ * 
+ * @author Martin Lowe
  *
  */
 @AutoValue
@@ -26,8 +28,9 @@ import com.google.auto.value.AutoValue;
 public abstract class GithubAccessToken {
 
     public abstract String getToken();
+
     public abstract LocalDateTime getExpiresAt();
-    
+
     public static Builder builder() {
         return new AutoValue_GithubAccessToken.Builder();
     }
@@ -36,6 +39,7 @@ public abstract class GithubAccessToken {
     @JsonPOJOBuilder(withPrefix = "set")
     public abstract static class Builder {
         public abstract Builder setToken(String token);
+
         public abstract Builder setExpiresAt(LocalDateTime expiresAt);
 
         public abstract GithubAccessToken build();
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java
index 7315e390..3783a36b 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubApplicationInstallation.java
@@ -16,7 +16,7 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.google.auto.value.AutoValue;
 
 /**
- * Information about the current
+ * Information about the given Github ECA application installation
  * 
  * @author Martin Lowe
  *
-- 
GitLab