diff --git a/config/application/secret.properties.sample b/config/application/secret.properties.sample index 99f4d517fdd6d498e991eae6bdd70c0255a4241c..47d466804ac9893d24b7b08c95d0e6ac47ae145d 100644 --- a/config/application/secret.properties.sample +++ b/config/application/secret.properties.sample @@ -16,4 +16,7 @@ eclipse.gitlab.access-token= ## Used to send mail through the EclipseFdn smtp connection quarkus.mailer.password=YOURGENERATEDAPPLICATIONPASSWORD -quarkus.mailer.username=YOUREMAIL@gmail.com \ No newline at end of file +quarkus.mailer.username=YOUREMAIL@gmail.com + +## github app bot id (this would be used to identify bot's comments) +eclipse.github.bot.id=s0m3ID \ No newline at end of file diff --git a/pom.xml b/pom.xml index ec754369e5983b43b31117963d17f85ad035badf..b712261f63fcbd37bf876df5313fe140284ec448 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,18 @@ <?xml version="1.0" encoding="UTF-8"?> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> +<!-- + Copyright (c) 2020 + 2025 Eclipse Foundation + + This program and the accompanying materials are made + available under the terms of the Eclipse Public License 2.0 + which is available at https://www.eclipse.org/legal/epl-2.0/ + + SPDX-License-Identifier: EPL-2.0 +--> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.eclipsefoundation</groupId> <artifactId>git-eca</artifactId> @@ -15,7 +28,7 @@ <quarkus.platform.version>3.15.4</quarkus.platform.version> <surefire-plugin.version>3.3.1</surefire-plugin.version> <maven.compiler.parameters>true</maven.compiler.parameters> - <eclipse-api-version>1.2.3</eclipse-api-version> + <eclipse-api-version>1.2.5</eclipse-api-version> <auto-value.version>1.10.4</auto-value.version> <org.mapstruct.version>1.5.5.Final</org.mapstruct.version> <sonar.sources>src/main</sonar.sources> @@ -75,8 +88,8 @@ <artifactId>quarkus-smallrye-jwt</artifactId> </dependency> <dependency> - <groupId>io.quarkus</groupId> - <artifactId>quarkus-mailer</artifactId> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-mailer</artifactId> </dependency> <!-- Annotation preprocessors - reduce all of the boiler plate --> @@ -97,6 +110,12 @@ <version>${org.mapstruct.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>io.soabase.record-builder</groupId> + <artifactId>record-builder-processor</artifactId> + <version>45</version> + <scope>provided</scope> + </dependency> <!-- Test requirements --> <dependency> @@ -168,6 +187,11 @@ <artifactId>mapstruct-processor</artifactId> <version>${org.mapstruct.version}</version> </path> + <path> + <groupId>io.soabase.record-builder</groupId> + <artifactId>record-builder-processor</artifactId> + <version>45</version> + </path> </annotationProcessorPaths> </configuration> </plugin> @@ -184,4 +208,4 @@ </plugin> </plugins> </build> -</project> +</project> \ No newline at end of file diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index 17f7229b0e8a52112b4d748f74aa918f6b0f2c66..a03a00481c34b41c044d2185049bcf072ac95d25 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -2,7 +2,7 @@ * Copyright (c) 2022 Eclipse Foundation * * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 + * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> @@ -19,6 +19,8 @@ import org.eclipsefoundation.git.eca.api.models.GithubAccessToken; import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationData; import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.jboss.resteasy.reactive.RestResponse; @@ -29,6 +31,7 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; @@ -59,7 +62,7 @@ public interface GithubAPI { /** * Retrieves a list of commits related to the given pull request. - * + * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param repo the repo name that is being targeted @@ -74,7 +77,7 @@ public interface GithubAPI { /** * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha. - * + * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param organization the organization that owns the targeted repo @@ -89,6 +92,41 @@ public interface GithubAPI { @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate); + /** + * Adds a comment to a pull request using the issues API. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param issueNumber the number of the issue/PR to which the comment will be added. + * @param commentRequest the comment request containing the comment message + * @return response indicating the result of the operation. + */ + @POST + @Path("repos/{org}/{repo}/issues/{issueNumber}/comments") + public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("issueNumber") int issueNumber, GithubIssueCommentRequest commentRequest); + + /** + * Lists comments on an issue/pull request. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param issueNumber the number of the issue/PR + * @param perPage number of items to return per page, max 100 + * @param page page number to return + * @return list of comments on the issue/pull request + */ + @GET + @Path("repos/{org}/{repo}/issues/{issueNumber}/comments") + public RestResponse<List<GithubIssueComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("issueNumber") int issueNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page); + /** * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given * application. @@ -117,7 +155,7 @@ public interface GithubAPI { /** * Returns a list of repositories for the given installation. - * + * * @param params the general params for requests, including pagination * @param bearer JWT bearer token for the target installation * @return list of repositories for the installation as a response for pagination diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java new file mode 100644 index 0000000000000000000000000000000000000000..91a798a7d5334e243b284e19e8c45aa4647d58b9 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipsefoundation.git.eca.api.models; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.soabase.recordbuilder.core.RecordBuilder; + +/** + * Model response for repos/{org}/{repo}/issues/{issueNumber}/comments + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@RecordBuilder +public record GithubIssueComment( + String body, + Long id, + GithubUser user +) { + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @RecordBuilder + public static record GithubUser( + Long id + ) {} +} diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..b6ec978dd2a6016e5bcaba8e38eb609ce293a051 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.git.eca.api.models; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.soabase.recordbuilder.core.RecordBuilder; + +/** + * Model for creating a comment on a GitHub issue or pull request. + * Uses the issues API endpoint which works for both issues and PRs. + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@RecordBuilder +public record GithubIssueCommentRequest(String body) {} \ No newline at end of file diff --git a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..8d26a3e156d84bde21e4c9c6af21acf8807e69c2 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.git.eca.config; + +import io.smallrye.config.ConfigMapping; +import java.util.Optional; + +/** + * The intention of this class is to provide an static bot ID, given that this app only supports one installation, this way we can avoid + * having to retrieve this information from the API on every start, or storing it in the DB. + */ +@ConfigMapping(prefix = "eclipse.github.bot") +public interface GithubBotConfig { + Optional<Long> id(); +} diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 803567edcab00ce75083e3d28e1bf6c4ab47613b..0f5e15a22ccc97236a72948008345a24ae8164b1 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -18,8 +18,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -30,8 +33,12 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequestBuilder; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; +import org.eclipsefoundation.git.eca.config.GithubBotConfig; import org.eclipsefoundation.git.eca.config.WebhooksConfig; import org.eclipsefoundation.git.eca.dto.CommitValidationStatus; import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation; @@ -53,9 +60,12 @@ import org.eclipsefoundation.persistence.model.RDBMSQuery; import org.eclipsefoundation.persistence.service.FilterService; import org.eclipsefoundation.utils.helper.DateTimeHelper; import org.eclipsefoundation.utils.helper.TransformationHelper; +import org.jboss.resteasy.reactive.RestResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; @@ -78,6 +88,9 @@ public class GithubHelper { @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") String apiVersion; + @Inject + GithubBotConfig botConfig; + @Inject WebhooksConfig webhooksConfig; @@ -102,10 +115,13 @@ public class GithubHelper { @RestClient GithubAPI ghApi; + @Location("github/validation_failure.md") + Template failureMessage; + /** * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and * validate the data. - * + * * @param wrapper the wrapper for the current request * @param org the name of the GH organization * @param repoName the slug of the repo that has the PR to be validated @@ -167,7 +183,7 @@ public class GithubHelper { /** * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the * installation ID for access/authorization. - * + * * @param installationId the ECA app installation ID for the organization * @param repositoryFullName the full name of the repository where the PR resides * @param pullRequestNumber the pull request number that is being validated @@ -225,7 +241,7 @@ public class GithubHelper { /** * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including * the existing ECA check, we don't need to check the commits here. - * + * * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit * status of the resources */ @@ -249,7 +265,7 @@ public class GithubHelper { /** * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more * info on the states of the validation for more information - * + * * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit * status of the resources * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic. @@ -275,14 +291,44 @@ public class GithubHelper { } else { LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.FAILURE); updateCommitStatus(request, GithubCommitStatuses.FAILURE); + commentOnFailure(request, + r + .getCommits() + .entrySet() + .stream() + .filter(e -> !e.getValue().getErrors().isEmpty()) + .map(e -> e.getKey()) + .map(hash -> findCommitByHash(vr, hash)) + .filter(Optional::isPresent) + .map(Optional::get) + .map(c -> c.getAuthor().getName()) + .collect(Collectors.toSet()), + r + .getCommits() + .values() + .stream() + .flatMap(c -> c.getErrors().stream()) + .map(e -> e.getMessage()) + .collect(Collectors.toSet())); } return r; } + /** + * Searches for a commit in the validation request based on its hash value. + * + * @param vr The validation request containing the list of commits to search + * @param hash The hash value to search for + * @return An Optional containing the matching Commit if found, or empty if not found + */ + private Optional<Commit> findCommitByHash(ValidationRequest vr, String hash) { + return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash())).findFirst(); + } + /** * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing * requests as well. - * + * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pr the pull request targeted by the validation request. @@ -297,7 +343,7 @@ public class GithubHelper { /** * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it. - * + * * @param tracking the optional tracking entry for the current request * @param request the pull request that is being validated * @param installationId the ECA app installation ID for the current request @@ -343,7 +389,7 @@ public class GithubHelper { /** * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number. - * + * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pullRequestNumber the pull request number that is being processed currently @@ -364,7 +410,7 @@ public class GithubHelper { * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before * the starting time. - * + * * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not * lead to a loss of data/integrity. */ @@ -405,7 +451,7 @@ public class GithubHelper { /** * Simple helper method so we don't have to repeat static strings in multiple places. - * + * * @param fullRepoName the full repo name including org for the target PR. * @return the full repo URL on Github for the request */ @@ -415,7 +461,7 @@ public class GithubHelper { /** * Sends off a POST to update the commit status given a context for the current PR. - * + * * @param request the current webhook update request * @param state the state to set the status to * @param fingerprint the internal unique string for the set of commits being processed @@ -427,9 +473,12 @@ public class GithubHelper { throw new IllegalStateException("Pull request should not be null when handling validation"); } - LOGGER - .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + if (LOGGER.isTraceEnabled()) { + LOGGER + .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + } + ghApi .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(), @@ -443,9 +492,104 @@ public class GithubHelper { .build()); } + /** + * Recursively fetches all comments from a pull request by handling pagination manually. + * + * @param bearer GitHub bearer token + * @param apiVersion GitHub API version + * @param org organization name + * @param repo repository name + * @param prNumber pull request number + * @return List of all comments from the pull request + */ + private List<GithubIssueComment> getAllPullRequestCommentsByUserId(String bearer, String apiVersion, String org, String repo, + int prNumber, Long userId) { + int perPage = 100; // GitHub's maximum items per page + int page = 1; // Start from the first page + List<GithubIssueComment> allComments = new ArrayList<>(); + + // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've + // reached the end + while (page < 50) { + RestResponse<List<GithubIssueComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); + + List<GithubIssueComment> comments = response.getEntity(); + if (comments == null || comments.isEmpty()) { + break; + } + + // We only want the comments made by the bot user + allComments.addAll(comments.stream().filter(comment -> comment.user().id() == userId).collect(Collectors.toList())); + page++; + } + + return allComments; + } + + /** + * This method posts a comment to the pull request detailing the validation errors and mentioning the usernames that need to take + * action. + * + * @param request The request containing repository and pull request information + * @param usernames Set of GitHub usernames that need to take action + * @param errors Set of error messages describing the validation failures + * + * @throws IllegalArgumentException if the request parameter is null + */ + private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, Set<String> errors) { + // This should never happen given the logic behind the getPassed, but adding this just in case that logic changes + if (errors.isEmpty()) { + return; + } + + String ghBearerString = jwtHelper.getGhBearerString(request.getInstallation().getId()); + String login = request.getRepository().getOwner().getLogin(); + String repositoryName = request.getRepository().getName(); + Integer pullRequestNumber = request.getPullRequest().getNumber(); + + Set<String> nonMentionedUsers = Set.copyOf(usernames); + + if (botConfig.id().isPresent()) { + // Get existing comments using pagination + List<GithubIssueComment> comments = getAllPullRequestCommentsByUserId(ghBearerString, apiVersion, login, repositoryName, + pullRequestNumber, botConfig.id().get()); + + nonMentionedUsers = usernames + .stream() + .filter(username -> comments + .stream() + .noneMatch( + comment -> Objects.requireNonNullElse(comment.body(), "").contains(String.format("@%s", username)))) + .collect(Collectors.toSet()); + } + + // If all the users have already been mentioned, skip commenting + if (nonMentionedUsers.isEmpty()) { + LOGGER.debug("All users have already been mentioned in the comments, skipping comment creation."); + return; + } + + GithubIssueCommentRequest comment = GithubIssueCommentRequestBuilder + .builder() + .body(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render()) + .build(); + + if (LOGGER.isTraceEnabled()) { + LOGGER + .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + } + + LOGGER + .trace("Adding new comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login), + TransformationHelper.formatLog(repositoryName), comment.body()); + + ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment); + } + /** * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved. - * + * * @param supplier the method with potentially nullable values * @return the value if it can be found, or null */ @@ -460,7 +604,7 @@ public class GithubHelper { /** * Validates required fields for processing requests. - * + * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pullRequestNumber the pull request number that is being processed currently @@ -490,7 +634,7 @@ public class GithubHelper { /** * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching. * Checks database for existing record, and returns record with a touched date for existing entries. - * + * * @param ghInstallation raw Github installation record for current application * @return the new or updated installation record to be persisted to the database. */ diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java index 95801ddf5a98deaf0d32f628383cd7696a124b2e..9d27dec43c186a76b4702eabe84262f88f2129d0 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java +++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java @@ -1,14 +1,14 @@ /********************************************************************* -* Copyright (c) 2020 Eclipse Foundation. -* -* This program and the accompanying materials are made -* available under the terms of the Eclipse Public License 2.0 -* which is available at https://www.eclipse.org/legal/epl-2.0/ -* -* Author: Martin Lowe <martin.lowe@eclipse-foundation.org> -* -* SPDX-License-Identifier: EPL-2.0 -**********************************************************************/ + * Copyright (c) 2020 Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ package org.eclipsefoundation.git.eca.model; import java.time.ZonedDateTime; @@ -26,7 +26,6 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.google.auto.value.AutoValue; -import com.google.auto.value.extension.memoized.Memoized; /** * Represents an internal response for a call to this API. @@ -35,7 +34,7 @@ import com.google.auto.value.extension.memoized.Memoized; */ @AutoValue @JsonNaming(LowerCamelCaseStrategy.class) -@JsonDeserialize(builder = $AutoValue_ValidationResponse.Builder.class) +@JsonDeserialize(builder = AutoValue_ValidationResponse.Builder.class) public abstract class ValidationResponse { public static final String NIL_HASH_PLACEHOLDER = "_nil"; @@ -54,7 +53,6 @@ public abstract class ValidationResponse { return getErrorCount() <= 0; } - @Memoized public int getErrorCount() { return getCommits().values().stream().mapToInt(s -> s.getErrors().size()).sum(); } diff --git a/src/main/resources/templates/github/validation_failure.md b/src/main/resources/templates/github/validation_failure.md new file mode 100644 index 0000000000000000000000000000000000000000..67f9243a92d70f08b8e92dcaec6f332bdb1035aa --- /dev/null +++ b/src/main/resources/templates/github/validation_failure.md @@ -0,0 +1,19 @@ +Hi{#for username in usernames} @{username}{/for} — thank you for your contribution! + +The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons: + +{#for reason in reasons} +- {reason} +{/for} + +To resolve this, please: + +1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca +1. Ensure your GitHub username is linked to your Eclipse account +1. Complete and submit the ECA form + +Once done, push a new commit (or rebase) to re-trigger the ECA validation. + +If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). + +Thanks again for your contribution! \ No newline at end of file diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java new file mode 100644 index 0000000000000000000000000000000000000000..7a8d3dd9aeeba8ac52a7782e455086876064f4e3 --- /dev/null +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -0,0 +1,308 @@ +/** + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipsefoundation.git.eca.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.eclipsefoundation.git.eca.api.GithubAPI; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; +import org.eclipsefoundation.git.eca.model.Commit; +import org.eclipsefoundation.git.eca.model.CommitStatus; +import org.eclipsefoundation.git.eca.model.GitUser; +import org.eclipsefoundation.git.eca.model.ValidationRequest; +import org.eclipsefoundation.git.eca.model.ValidationResponse; +import org.eclipsefoundation.git.eca.namespace.APIStatusCode; +import org.eclipsefoundation.git.eca.namespace.ProviderType; +import org.eclipsefoundation.git.eca.service.ValidationService; +import org.eclipsefoundation.git.eca.test.api.MockGithubAPI; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; + +@QuarkusTest +class GithubHelperTest { + + @Inject + GithubHelper helper; + + @RestClient + @Inject + GithubAPI ghApi; + + @InjectMock + ValidationService validationService; + + /** + * Clean up the test environment before each test. This is important for Quarkus tests as beans maintain state between tests. + */ + @BeforeEach + void setUp() { + // Clean up the MockGithubAPI state if it's our mock implementation + if (ghApi instanceof MockGithubAPI mockGithubApi) { + mockGithubApi.cleanState(); + } + + // Reset mock interactions and stubbing + Mockito.reset(validationService); + } + + /** + * Test validation when multiple commits have ECA validation errors. Verifies that all users with invalid commits are properly notified. + */ + @Test + void testHandleGithubWebhookValidation_MultipleCommitErrors() { + // Set up test data for the PR + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); + Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com"); + + // Create webhook request and validation request + GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + ValidationRequest vr = createValidationRequest(orgName, repoName, List.of(testCommit1, testCommit2)); + + // Create a mock validation response with multiple errors + ValidationResponse mockResponse = createErrorValidationResponse( + Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error"))); + + // Mock validation service to return our error response + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse); + + // Execute the validation handler + helper.handleGithubWebhookValidation(request, vr, null); + + // Verify that the correct error comment was added to the PR + verifyErrorComment(orgName, repoName, prNumber, List.of("testUser", "testUser2"), List.of("Missing ECA for user", "Another error")); + } + + /** + * Test validation when new commits are added to a PR where some users were previously notified. Verifies that only new users are + * notified of validation errors. + */ + @Test + void testHandleGithubWebhookValidation_OnlyNewUsersNotified() { + // Set up test data + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + // First validation with initial commits + Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); + GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + ValidationRequest initialVr = createValidationRequest(orgName, repoName, List.of(testCommit1)); + + // Mock initial validation response + ValidationResponse initialMockResponse = createErrorValidationResponse(Map.of(testCommit1, List.of("Missing ECA for user"))); + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(initialMockResponse); + + // Execute initial validation + helper.handleGithubWebhookValidation(request, initialVr, null); + + // Verify initial error comment + verifyErrorComment(orgName, repoName, prNumber, List.of("testUser"), List.of("Missing ECA for user")); + + // Second validation with new commit + Commit testCommit3 = createTestCommit("ghi789", "testUser3", "test3@example.com"); + ValidationRequest newVr = createValidationRequest(orgName, repoName, List.of(testCommit1, testCommit3)); + + // Mock new validation response + ValidationResponse newMockResponse = createErrorValidationResponse( + Map.of(testCommit1, List.of("Missing ECA for user"), testCommit3, List.of("Missing ECA for user", "Another error"))); + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(newMockResponse); + + // Execute new validation + helper.handleGithubWebhookValidation(request, newVr, null); + + // Verify that only the new user is notified + verifyErrorComment(orgName, repoName, prNumber, List.of("testUser3"), List.of("Missing ECA for user", "Another error")); + } + + @Test + void testHandleGithubWebhookValidation_NoErrors() { + // Set up test data for the PR + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com"); + + // Create webhook request simulating a GitHub PR event + GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + ValidationRequest vr = createValidationRequest(orgName, repoName, List.of(testCommit)); + + // Create a mock successful validation response with no errors + ValidationResponse mockResponse = ValidationResponse + .builder() + .setStrictMode(false) + .setTrackedProject(true) + .setCommits(Map.of("abc123", CommitStatus.builder().build())) + .build(); + + // Configure mock to return our success response + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse); + + // Execute the validation handler + helper.handleGithubWebhookValidation(request, vr, null); + + // Verify no comments were added + var comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity(); + assertTrue(comments.isEmpty(), "No comments should be added when there are no errors"); + } + + /** + * Creates a GitHub webhook request for testing purposes. + * + * @param installationId The installation ID + * @param repoName The repository name + * @param orgName The organization name + * @param prNumber The pull request number + * @param commitSha The commit SHA + * @return GithubWebhookRequest instance + */ + private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, String orgName, int prNumber, + String commitSha) { + return GithubWebhookRequest + .builder() + .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build()) + .setRepository(GithubWebhookRequest.Repository + .builder() + .setName(repoName) + .setFullName(orgName + "/" + repoName) + .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build()) + .setHtmlUrl("https://github.com/" + orgName + "/" + repoName) + .build()) + .setPullRequest(GithubWebhookRequest.PullRequest + .builder() + .setNumber(prNumber) + .setState("open") + .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build()) + .build()) + .build(); + } + + /** + * Creates a test commit with specified user details. + * + * @param commitHash The commit hash + * @param userName The user's name + * @param userEmail The user's email + * @return Commit instance + */ + private Commit createTestCommit(String commitHash, String userName, String userEmail) { + GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build(); + return Commit + .builder() + .setHash(commitHash) + .setAuthor(testUser) + .setCommitter(testUser) + .setSubject("Test commit") + .setBody("Test commit body") + .setParents(Collections.emptyList()) + .build(); + } + + /** + * Creates a validation response with an error status. + * + * @param commitHash The commit hash to associate the error with + * @param errorMessage The error message + * @return ValidationResponse instance + */ + private ValidationResponse createErrorValidationResponse(Map<Commit, List<String>> commitErrors) { + Map<String, CommitStatus> commits = commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> { + CommitStatus status = CommitStatus.builder().build(); + entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR)); + map.put(entry.getKey().getHash(), status); + }, HashMap::putAll); + + return ValidationResponse + .builder() + .setTime(ZonedDateTime.now()) + .setCommits(commits) + .setTrackedProject(true) + .setStrictMode(true) + .build(); + } + + /** + * Creates a validation request for testing. + * + * @param orgName The organization name + * @param repoName The repository name + * @param commits The list of commits to validate + * @return ValidationRequest instance + */ + private ValidationRequest createValidationRequest(String orgName, String repoName, List<Commit> commits) { + return ValidationRequest + .builder() + .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) + .setProvider(ProviderType.GITHUB) + .setCommits(commits) + .build(); + } + + /** + * Verifies that an error comment was posted to GitHub with the expected content. + * + * @param orgName The organization name + * @param repoName The repository name + * @param prNumber The pull request number + * @param usernames List of GitHub usernames to be mentioned + * @param errors List of error messages + */ + private void verifyErrorComment(String orgName, String repoName, int prNumber, List<String> usernames, List<String> errors) { + String mentions = usernames.stream().map(user -> "@" + user).collect(java.util.stream.Collectors.joining(" ")); + String errorBullets = errors.stream().map(error -> "- " + error).collect(java.util.stream.Collectors.joining("\n")); + + String expectedBody = String + .format(""" + Hi %s — thank you for your contribution! + + The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons: + + %s + + To resolve this, please: + + 1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca + 1. Ensure your GitHub username is linked to your Eclipse account + 1. Complete and submit the ECA form + + Once done, push a new commit (or rebase) to re-trigger the ECA validation. + + If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). + + Thanks again for your contribution!""", + mentions, errorBullets); + + // Get the comments and verify content + List<GithubIssueComment> comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity(); + assertEquals(1, comments.stream().filter(comment -> comment.body().equals(expectedBody)).toList().size(), + "Expected one comment with the expected body"); + } + +} diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 7031f2d011f592ff731295737395743ff8749c9e..011d87550a63414aec5f3173016c85471e733826 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -1,26 +1,24 @@ /********************************************************************* -* Copyright (c) 2023 Eclipse Foundation. -* -* This program and the accompanying materials are made -* available under the terms of the Eclipse Public License 2.0 -* which is available at https://www.eclipse.org/legal/epl-2.0/ -* -* Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org> -* -* SPDX-License-Identifier: EPL-2.0 -**********************************************************************/ + * Copyright (c) 2023 Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org> + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ package org.eclipsefoundation.git.eca.test.api; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.ws.rs.core.Response; - import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters; import org.eclipsefoundation.git.eca.api.GithubAPI; @@ -31,10 +29,18 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentBuilder; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentGithubUserBuilder; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; +import org.eclipsefoundation.git.eca.config.GithubBotConfig; import org.jboss.resteasy.reactive.RestResponse; import io.quarkus.test.Mock; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; @Mock @RestClient @@ -43,10 +49,15 @@ public class MockGithubAPI implements GithubAPI { Map<String, Map<Integer, List<GithubCommit>>> commits; Map<String, Map<String, String>> commitStatuses; + Map<String, Map<Integer, List<GithubIssueComment>>> comments; + + @Inject + GithubBotConfig githubBotConfig; public MockGithubAPI() { this.commitStatuses = new HashMap<>(); this.commits = new HashMap<>(); + this.comments = new HashMap<>(); this.commits .put("eclipsefdn/sample", Map @@ -110,4 +121,45 @@ public class MockGithubAPI implements GithubAPI { public Response getInstallationRepositories(BaseAPIParameters params, String bearer) { throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'"); } + + @Override + public RestResponse<List<GithubIssueComment>> getComments(String bearer, String apiVersion, String organization, String repo, + int pullNumber, int perPage, int page) { + + // to avoid loops when loading all comments + if (page > 1) { + return RestResponse.ok(Collections.emptyList()); + } + + String repoFullName = organization + '/' + repo; + Map<Integer, List<GithubIssueComment>> repoMap = comments.getOrDefault(repoFullName, Collections.emptyMap()); + List<GithubIssueComment> repoComments = repoMap.getOrDefault(pullNumber, Collections.emptyList()); + return RestResponse.ok(repoComments); + } + + @Override + public Response addComment(String bearer, String apiVersion, String organization, String repo, int issueNumber, + GithubIssueCommentRequest commentRequest) { + String repoFullName = organization + '/' + repo; + GithubIssueComment comment = GithubIssueCommentBuilder.builder() + .id(0L) + .body(commentRequest.body()) + .user(GithubIssueCommentGithubUserBuilder.builder().id(githubBotConfig.id().orElse(0L)).build()) + .build(); + + comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment); + + return Response.ok().build(); + } + + /** + * Cleans up the internal state of the mock. + * This method should be called before each test to ensure a clean state. + */ + public void cleanState() { + this.commits = new HashMap<>(); + this.commitStatuses = new HashMap<>(); + this.comments = new HashMap<>(); + } + } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index bd6e17d86aaa0dd58b868dcb16386a4bdc8f1ef4..4a6a40751bbd9f6c467eed308aa9428ec8c3d2b9 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -44,3 +44,6 @@ eclipse.gitlab.access-token=token_val ## Disable private project scan in test mode eclipse.scheduled.private-project.enabled=false + +## GitHub App bot ID +eclipse.github.bot.id=111111 \ No newline at end of file