From fbd174bcedaa1db0a38de62246fcb42a30341690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Tue, 22 Apr 2025 16:36:30 +0200 Subject: [PATCH 01/14] feat: adding base support for sending messages when a PR validation fails --- .../git/eca/api/GithubAPI.java | 184 ++-- .../git/eca/helper/GithubHelper.java | 945 +++++++++--------- .../git/eca/model/ValidationResponse.java | 4 +- .../git/eca/helper/GithubHelperTest.java | 133 +++ .../git/eca/test/api/MockGithubAPI.java | 143 +-- 5 files changed, 808 insertions(+), 601 deletions(-) create mode 100644 src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index 17f7229b..bced49eb 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -1,9 +1,8 @@ /** * Copyright (c) 2022 Eclipse Foundation * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> * @@ -42,88 +41,111 @@ import jakarta.ws.rs.core.Response; @Produces("application/json") public interface GithubAPI { - /** - * Retrieves information about a certain pull request in a repo if it exists - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param repoFull the full repo name that is being targeted - * @param pullNumber the pull request number - * @return information about the given pull request if it exists. - */ - @GET - @Path("repos/{org}/{repo}/pulls/{pullNumber}") - public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, - @PathParam("pullNumber") int pullNumber); + /** + * Retrieves information about a certain pull request in a repo if it exists + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param repoFull the full repo name that is being targeted + * @param pullNumber the pull request number + * @return information about the given pull request if it exists. + */ + @GET + @Path("repos/{org}/{repo}/pulls/{pullNumber}") + public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, + @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber); - /** - * Retrieves a list of commits related to the given pull request. - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param repo the repo name that is being targeted - * @param pullNumber the pull request number - * @return list of commits associated with the pull request, wrapped in a jax-rs response - */ - @GET - @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits") - public RestResponse<List<GithubCommit>> getCommits(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, - @PathParam("pullNumber") int pullNumber); + /** + * Retrieves a list of commits related to the given pull request. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param repo the repo name that is being targeted + * @param pullNumber the pull request number + * @return list of commits associated with the pull request, wrapped in a jax-rs response + */ + @GET + @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits") + public RestResponse<List<GithubCommit>> getCommits( + @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, + @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber); - /** - * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha. - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param organization the organization that owns the targeted repo - * @param repo the repo name that is being targeted - * @param prHeadSha the head SHA for the request - * @param commitStatusUpdate the status body sent with the request - * @return JAX-RS response to check for success or failure based on status code. - */ - @POST - @Path("repos/{org}/{repo}/statuses/{prHeadSha}") - public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, - @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate); + /** + * Posts an update to the Github API, using an access token to update a given pull requests commit + * status, targeted using the head sha. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param prHeadSha the head SHA for the request + * @param commitStatusUpdate the status body sent with the request + * @return JAX-RS response to check for success or failure based on status code. + */ + @POST + @Path("repos/{org}/{repo}/statuses/{prHeadSha}") + public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, + @PathParam("repo") String repo, @PathParam("prHeadSha") String prHeadSha, + GithubCommitStatusRequest commitStatusUpdate); - /** - * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given - * application. - * - * @param params the general params for requests, including pagination - * @param bearer JWT bearer token for the target application - * @return list of installations for the application - */ - @GET - @Path("app/installations") - public RestResponse<List<GithubApplicationInstallationData>> getInstallations(@BeanParam BaseAPIParameters params, - @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); + /** + * Adds a comment to a pull request. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param pullNumber the number of the pull request to which the comment will be added. + * @param comment the content of the comment to add. + * @return response indicating the result of the operation. + */ + @POST + @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") + public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, + @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, String comment); - /** - * Retrieves an access token for a specific installation, given the applications JWT bearer and the api version. - * - * @param bearer the authorization header value, should contain the apps signed JWT token - * @param apiVersion the API version to target with the request - * @param installationId the installation to generate an access token for - * @return the Github access token for the GH app installation - */ - @POST - @Path("app/installations/{installationId}/access_tokens") - public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("installationId") String installationId); + /** + * Requires a JWT bearer token for the application to retrieve installations for. Returns a list + * of installations for the given application. + * + * @param params the general params for requests, including pagination + * @param bearer JWT bearer token for the target application + * @return list of installations for the application + */ + @GET + @Path("app/installations") + public RestResponse<List<GithubApplicationInstallationData>> getInstallations( + @BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); - /** - * Returns a list of repositories for the given installation. - * - * @param params the general params for requests, including pagination - * @param bearer JWT bearer token for the target installation - * @return list of repositories for the installation as a response for pagination - */ - @GET - @Path("installation/repositories") - public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); + /** + * Retrieves an access token for a specific installation, given the applications JWT bearer and + * the api version. + * + * @param bearer the authorization header value, should contain the apps signed JWT token + * @param apiVersion the API version to target with the request + * @param installationId the installation to generate an access token for + * @return the Github access token for the GH app installation + */ + @POST + @Path("app/installations/{installationId}/access_tokens") + public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, + @PathParam("installationId") String installationId); + + /** + * Returns a list of repositories for the given installation. + * + * @param params the general params for requests, including pagination + * @param bearer JWT bearer token for the target installation + * @return list of repositories for the installation as a response for pagination + */ + @GET + @Path("installation/repositories") + public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, + @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); } diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 803567ed..3a744772 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -1,9 +1,8 @@ /** * Copyright (c) 2023 Eclipse Foundation * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> * @@ -19,8 +18,9 @@ import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; - +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; @@ -66,472 +66,525 @@ import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; /** - * This class is used to adapt Github requests to the standard validation workflow in a way that could be reused by both resource calls and - * scheduled tasks for revalidation. + * This class is used to adapt Github requests to the standard validation workflow in a way that + * could be reused by both resource calls and scheduled tasks for revalidation. */ @ApplicationScoped public class GithubHelper { - private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class); - - private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}"; - - @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") - String apiVersion; - - @Inject - WebhooksConfig webhooksConfig; - - @Inject - JwtHelper jwtHelper; - @Inject - APIMiddleware middleware; - @Inject - PersistenceDao dao; - @Inject - FilterService filters; - @Inject - JwtHelper jwt; - - @Inject - ValidationService validation; - @Inject - ValidationStatusService validationStatus; - @Inject - GithubApplicationService ghAppService; - - @RestClient - GithubAPI ghApi; - - /** - * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and - * validate the data. - * - * @param wrapper the wrapper for the current request - * @param org the name of the GH organization - * @param repoName the slug of the repo that has the PR to be validated - * @param prNo the PR number for the current request. - * @param forceRevalidation true if revalidation should be forced when there is no changes, false otherwise. - * @return the validated response if it is a valid request, or throws a web exception if there is a problem validating the request. - */ - public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, String repoName, Integer prNo, - boolean forceRevalidation) { - // use logging helper to sanitize newlines as they aren't needed here - String fullRepoName = getFullRepoName(org, repoName); - // get the installation ID for the given repo if it exists, and if the PR noted exists - String installationId = ghAppService.getInstallationForRepo(org, repoName); - Optional<PullRequest> prResponse = ghAppService.getPullRequest(installationId, org, repoName, prNo); - if (StringUtils.isBlank(installationId)) { - throw new BadRequestException("Could not find an ECA app installation for repo name: " + fullRepoName); - } else if (prResponse.isEmpty()) { - throw new NotFoundException(String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName)); - } - - // prepare the request for consumption - String repoUrl = getRepoUrl(org, repoName); - ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl); - - // build the commit sha list based on the prepared request - List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList(); - // there should always be commits for a PR, but in case, lets check - if (commitShas.isEmpty()) { - throw new BadRequestException(String.format("Could not find any commits for %s#%d", fullRepoName, prNo)); - } - LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); - - // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing. - GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName, - prResponse.get()); - if (updatedTracking == null) { - throw new ServerErrorException("Error while attempting to revalidate request, try again later.", - Response.Status.INTERNAL_SERVER_ERROR); - } - - // get the commit status of commits to use for checking historic validation - List<CommitValidationStatus> statuses = validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas); - if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) { - throw new BadRequestException("Cannot find validation history for current non-open PR, cannot provide validation status"); - } - - // we only want to update/revalidate for open PRs, so don't do this check if the PR is merged/closed - if (forceRevalidation) { - LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo); - return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); - } else if ("open".equalsIgnoreCase(prResponse.get().getState()) && commitShas.size() != statuses.size()) { - LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo); - // using the updated tracking, perform the validation - return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); - } - return null; + private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class); + + private static final String VALIDATION_LOGGING_MESSAGE = + "Setting validation state for {}/#{} to {}"; + + @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") + String apiVersion; + + @Inject + WebhooksConfig webhooksConfig; + + @Inject + JwtHelper jwtHelper; + @Inject + APIMiddleware middleware; + @Inject + PersistenceDao dao; + @Inject + FilterService filters; + @Inject + JwtHelper jwt; + + @Inject + ValidationService validation; + @Inject + ValidationStatusService validationStatus; + @Inject + GithubApplicationService ghAppService; + + @RestClient + GithubAPI ghApi; + + /** + * Using the wrapper and the passed unique information about a Github validation instance, lookup + * or create the tracking request and validate the data. + * + * @param wrapper the wrapper for the current request + * @param org the name of the GH organization + * @param repoName the slug of the repo that has the PR to be validated + * @param prNo the PR number for the current request. + * @param forceRevalidation true if revalidation should be forced when there is no changes, false + * otherwise. + * @return the validated response if it is a valid request, or throws a web exception if there is + * a problem validating the request. + */ + public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, + String repoName, Integer prNo, boolean forceRevalidation) { + // use logging helper to sanitize newlines as they aren't needed here + String fullRepoName = getFullRepoName(org, repoName); + // get the installation ID for the given repo if it exists, and if the PR noted exists + String installationId = ghAppService.getInstallationForRepo(org, repoName); + Optional<PullRequest> prResponse = + ghAppService.getPullRequest(installationId, org, repoName, prNo); + if (StringUtils.isBlank(installationId)) { + throw new BadRequestException( + "Could not find an ECA app installation for repo name: " + fullRepoName); + } else if (prResponse.isEmpty()) { + throw new NotFoundException( + String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName)); } - /** - * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the - * installation ID for access/authorization. - * - * @param installationId the ECA app installation ID for the organization - * @param repositoryFullName the full name of the repository where the PR resides - * @param pullRequestNumber the pull request number that is being validated - * @param repositoryUrl the URL of the repository that contains the commits to validate - * @return the populated validation request for the Github request information - */ - @SuppressWarnings("null") - public ValidationRequest generateRequest(String installationId, String orgName, String repositoryName, int pullRequestNumber, - String repositoryUrl) { - checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); - // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful - if (LOGGER.isTraceEnabled()) { - LOGGER - .trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, TransformationHelper.formatLog(orgName), - TransformationHelper.formatLog(repositoryName)); - } - List<GithubCommit> commits = middleware - .getAll(i -> ghApi - .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, orgName, repositoryName, pullRequestNumber)); - if (LOGGER.isTraceEnabled()) { - LOGGER - .trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber, - TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName)); - } - - // set up the validation request from current data - return ValidationRequest - .builder() - .setProvider(ProviderType.GITHUB) - .setRepoUrl(URI.create(repositoryUrl)) - .setStrictMode(true) - .setCommits(commits - .stream() - .map(c -> Commit - .builder() - .setHash(c.getSha()) - .setAuthor(GitUser - .builder() - .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail())) - .setName(getNullableString(() -> c.getCommit().getAuthor().getName())) - .setExternalId(getNullableString(() -> c.getAuthor().getLogin())) - .build()) - .setCommitter(GitUser - .builder() - .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail())) - .setName(getNullableString(() -> c.getCommit().getCommitter().getName())) - .setExternalId(getNullableString(() -> c.getCommitter().getLogin())) - .build()) - .setParents(c.getParents().stream().map(ParentCommit::getSha).toList()) - .build()) - .toList()) - .build(); - } + // prepare the request for consumption + String repoUrl = getRepoUrl(org, repoName); + ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl); - /** - * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including - * the existing ECA check, we don't need to check the commits here. - * - * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit - * status of the resources - */ - public void sendMergeQueueStatus(GithubWebhookRequest request) { - if (request.getMergeGroup() == null) { - throw new BadRequestException("Merge group object required in webhook request to send status for merge queue"); - } - // send the success status for the head SHA - ghApi - .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, - request.getRepository().getOwner().getLogin(), request.getRepository().getName(), - request.getMergeGroup().getHeadSha(), - GithubCommitStatusRequest - .builder() - .setDescription("Commits in merge group should be previously validated, auto-passing HEAD commit") - .setState(GithubCommitStatuses.SUCCESS.toString()) - .setContext(webhooksConfig.github().context()) - .build()); + // build the commit sha list based on the prepared request + List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList(); + // there should always be commits for a PR, but in case, lets check + if (commitShas.isEmpty()) { + throw new BadRequestException( + String.format("Could not find any commits for %s#%d", fullRepoName, prNo)); } - - /** - * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more - * info on the states of the validation for more information - * - * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit - * status of the resources - * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic. - * @return true if the validation passed, false otherwise. - */ - public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr, RequestWrapper wrapper) { - // null check the pull request to make sure that someone didn't push a bad value - PullRequest pr = request.getPullRequest(); - if (pr == null) { - throw new IllegalStateException("Pull request should not be null when handling validation"); - } - - // update the status before processing - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.PENDING); - updateCommitStatus(request, GithubCommitStatuses.PENDING); - - // validate the response - LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(), pr.getNumber()); - ValidationResponse r = validation.validateIncomingRequest(vr, wrapper); - if (r.getPassed()) { - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.SUCCESS); - updateCommitStatus(request, GithubCommitStatuses.SUCCESS); - } else { - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.FAILURE); - updateCommitStatus(request, GithubCommitStatuses.FAILURE); - } - return r; + LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); + + // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing. + GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, + installationId, org, repoName, prResponse.get()); + if (updatedTracking == null) { + throw new ServerErrorException( + "Error while attempting to revalidate request, try again later.", + Response.Status.INTERNAL_SERVER_ERROR); } - /** - * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing - * requests as well. - * - * @param installationId the installation ID for the ECA app in the given repository - * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty - * @param pr the pull request targeted by the validation request. - * @return a new or updated tracking object, or null if there was an error in saving the information - */ - public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, String installationId, String orgName, - String repositoryName, PullRequest pr) { - return updateGithubTrackingIfMissing(wrapper, - getExistingRequestInformation(wrapper, installationId, orgName, repositoryName, pr.getNumber()), pr, installationId, - orgName, repositoryName); + // get the commit status of commits to use for checking historic validation + List<CommitValidationStatus> statuses = + validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas); + if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) { + throw new BadRequestException( + "Cannot find validation history for current non-open PR, cannot provide validation status"); } - /** - * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it. - * - * @param tracking the optional tracking entry for the current request - * @param request the pull request that is being validated - * @param installationId the ECA app installation ID for the current request - * @param fullRepoName the full repo name for the validation request - */ - public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, Optional<GithubWebhookTracking> tracking, - PullRequest request, String installationId, String orgName, String repositoryName) { - String fullRepoName = getFullRepoName(orgName, repositoryName); - // if there is no tracking present, create the missing tracking and persist it - GithubWebhookTracking updatedTracking; - if (tracking.isEmpty()) { - updatedTracking = new GithubWebhookTracking(); - updatedTracking.setInstallationId(installationId); - updatedTracking.setLastUpdated(DateTimeHelper.now()); - updatedTracking.setPullRequestNumber(request.getNumber()); - updatedTracking.setRepositoryFullName(fullRepoName); - updatedTracking.setNeedsRevalidation(false); - updatedTracking.setManualRevalidationCount(0); - if (!"open".equalsIgnoreCase(request.getState())) { - LOGGER - .warn("The PR {} in {} is not in an open state, and will not be validated to follow our validation practice", - updatedTracking.getPullRequestNumber(), fullRepoName); - } - } else { - // uses the DB version as a base if available - updatedTracking = tracking.get(); - } - // always update the head SHA and the last known state - updatedTracking.setLastKnownState(request.getState()); - updatedTracking.setHeadSha(request.getHead().getSha()); - - // save the data, and log on its success or failure - List<GithubWebhookTracking> savedTracking = dao - .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(updatedTracking)); - if (savedTracking.isEmpty()) { - LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber()); - return null; - } - // return the updated tracking when successful - LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber()); - return savedTracking.get(0); + // we only want to update/revalidate for open PRs, so don't do this check if the PR is + // merged/closed + if (forceRevalidation) { + LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo); + return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), + vr, wrapper); + } else if ("open".equalsIgnoreCase(prResponse.get().getState()) + && commitShas.size() != statuses.size()) { + LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", + fullRepoName, prNo); + // using the updated tracking, perform the validation + return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), + vr, wrapper); } - - /** - * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number. - * - * @param installationId the installation ID for the ECA app in the given repository - * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty - * @param pullRequestNumber the pull request number that is being processed currently - * @return the webhook tracking record if it can be found, or an empty optional. - */ - public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, String installationId, String orgName, - String repositoryName, int pullRequestNumber) { - checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); - String repositoryFullName = getFullRepoName(orgName, repositoryName); - MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId); - params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName); - params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber)); - return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst(); + return null; + } + + /** + * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the + * noted pull request using the installation ID for access/authorization. + * + * @param installationId the ECA app installation ID for the organization + * @param repositoryFullName the full name of the repository where the PR resides + * @param pullRequestNumber the pull request number that is being validated + * @param repositoryUrl the URL of the repository that contains the commits to validate + * @return the populated validation request for the Github request information + */ + @SuppressWarnings("null") + public ValidationRequest generateRequest(String installationId, String orgName, + String repositoryName, int pullRequestNumber, String repositoryUrl) { + checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); + // get the commits that will be validated, don't cache as changes can come in too fast for it to + // be useful + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, + TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName)); } - - /** - * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing - * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before - * the starting time. - * - * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not - * lead to a loss of data/integrity. - */ - public void updateAppInstallationRecords() { - // get the installations for the currently configured app - List<GithubApplicationInstallationData> installations = middleware - .getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt())); - // check that there are installations, none may indicate an issue, and better to fail pessimistically - if (installations.isEmpty()) { - LOGGER.warn("Did not find any installations for the currently configured Github application"); - return; - } - // trace log the installations for more context - LOGGER.debug("Found {} installations to cache", installations.size()); - - // create a common timestamp that looks for entries stale for more than a day - Date startingTimestamp = new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli()); - // from installations, build records and start the processing for each entry - List<GithubApplicationInstallation> installationRecords = installations.stream().map(this::processInstallation).toList(); - - // once records are prepared, persist them back to the database with updates where necessary as a batch - RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations")); - List<GithubApplicationInstallation> repoRecords = dao - .add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), installationRecords); - if (repoRecords.size() != installationRecords.size()) { - LOGGER.warn("Background update to installation records had a size mismatch, cleaning will be skipped for this run"); - return; - } - - // build query to do cleanup of stale records - MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId())); - params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, DateTimeHelper.toRFC3339(startingTimestamp)); - - // run the delete call, removing stale entries - dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); + List<GithubCommit> commits = + middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId), + apiVersion, orgName, repositoryName, pullRequestNumber)); + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber, + TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName)); } - /** - * Simple helper method so we don't have to repeat static strings in multiple places. - * - * @param fullRepoName the full repo name including org for the target PR. - * @return the full repo URL on Github for the request - */ - public static String getRepoUrl(String org, String repoName) { - return "https://github.com/" + org + '/' + repoName; + // set up the validation request from current data + return ValidationRequest.builder().setProvider(ProviderType.GITHUB) + .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true) + .setCommits(commits.stream() + .map(c -> Commit.builder().setHash(c.getSha()) + .setAuthor(GitUser.builder() + .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail())) + .setName(getNullableString(() -> c.getCommit().getAuthor().getName())) + .setExternalId(getNullableString(() -> c.getAuthor().getLogin())).build()) + .setCommitter(GitUser.builder() + .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail())) + .setName(getNullableString(() -> c.getCommit().getCommitter().getName())) + .setExternalId(getNullableString(() -> c.getCommitter().getLogin())).build()) + .setParents(c.getParents().stream().map(ParentCommit::getSha).toList()).build()) + .toList()) + .build(); + } + + /** + * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As + * commits need to pass branch rules including the existing ECA check, we don't need to check the + * commits here. + * + * @param request information about the request from the GH webhook on what resource requested + * revalidation. Used to target the commit status of the resources + */ + public void sendMergeQueueStatus(GithubWebhookRequest request) { + if (request.getMergeGroup() == null) { + throw new BadRequestException( + "Merge group object required in webhook request to send status for merge queue"); } - - /** - * Sends off a POST to update the commit status given a context for the current PR. - * - * @param request the current webhook update request - * @param state the state to set the status to - * @param fingerprint the internal unique string for the set of commits being processed - */ - private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) { - PullRequest pr = request.getPullRequest(); - if (pr == null) { - // should not be reachable, but for safety - throw new IllegalStateException("Pull request should not be null when handling validation"); - } - - LOGGER - .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); - ghApi - .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, - request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(), - GithubCommitStatusRequest - .builder() - .setDescription(state.getMessage()) - .setState(state.toString()) - .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" - + request.getRepository().getFullName() + '/' + pr.getNumber()) - .setContext(webhooksConfig.github().context()) - .build()); + // send the success status for the head SHA + ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, + request.getRepository().getOwner().getLogin(), request.getRepository().getName(), + request.getMergeGroup().getHeadSha(), + GithubCommitStatusRequest.builder() + .setDescription( + "Commits in merge group should be previously validated, auto-passing HEAD commit") + .setState(GithubCommitStatuses.SUCCESS.toString()) + .setContext(webhooksConfig.github().context()).build()); + } + + private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) { + if (errors.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append("The following errors were found in the validation of this pull request:\n"); + for (String error : errors) { + sb.append("- ").append(error).append('\n'); + } + sb.append("Please check the ECA validation status for more information."); + + ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, + request.getRepository().getOwner().getLogin(), request.getRepository().getName(), + request.getPullRequest().getNumber(), sb.toString()); + } + + /** + * Process the current request and update the checks state to pending then success or failure. + * Contains verbose TRACE logging for more info on the states of the validation for more + * information + * + * @param request information about the request from the GH webhook on what resource requested + * revalidation. Used to target the commit status of the resources + * @param vr the pseudo request generated from the contextual webhook data. Used to make use of + * existing validation logic. + * @return true if the validation passed, false otherwise. + */ + public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, + ValidationRequest vr, RequestWrapper wrapper) { + // null check the pull request to make sure that someone didn't push a bad value + PullRequest pr = request.getPullRequest(); + if (pr == null) { + throw new IllegalStateException("Pull request should not be null when handling validation"); } - /** - * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved. - * - * @param supplier the method with potentially nullable values - * @return the value if it can be found, or null - */ - private String getNullableString(Supplier<String> supplier) { - try { - return supplier.get(); - } catch (NullPointerException e) { - // suppress, as we don't care at this point if its null - } - return null; + // update the status before processing + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), + GithubCommitStatuses.PENDING); + updateCommitStatus(request, GithubCommitStatuses.PENDING); + + // validate the response + LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(), + pr.getNumber()); + ValidationResponse r = validation.validateIncomingRequest(vr, wrapper); + if (r.getPassed()) { + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), + pr.getNumber(), GithubCommitStatuses.SUCCESS); + updateCommitStatus(request, GithubCommitStatuses.SUCCESS); + } else { + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), + pr.getNumber(), GithubCommitStatuses.FAILURE); + updateCommitStatus(request, GithubCommitStatuses.FAILURE); + commentOnFailure(request, + r.getCommits().values().stream().flatMap(c -> c.getErrors().stream()) + .map(e -> e.getMessage()).collect(Collectors.toSet())); + } + return r; + } + + /** + * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, + * and will update the state of existing requests as well. + * + * @param installationId the installation ID for the ECA app in the given repository + * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty + * @param pr the pull request targeted by the validation request. + * @return a new or updated tracking object, or null if there was an error in saving the + * information + */ + public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, + String installationId, String orgName, String repositoryName, PullRequest pr) { + return updateGithubTrackingIfMissing(wrapper, getExistingRequestInformation(wrapper, + installationId, orgName, repositoryName, pr.getNumber()), pr, installationId, orgName, + repositoryName); + } + + /** + * Checks if the Github tracking is present for the current request, and if missing will generate + * a new record and save it. + * + * @param tracking the optional tracking entry for the current request + * @param request the pull request that is being validated + * @param installationId the ECA app installation ID for the current request + * @param fullRepoName the full repo name for the validation request + */ + public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, + Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId, + String orgName, String repositoryName) { + String fullRepoName = getFullRepoName(orgName, repositoryName); + // if there is no tracking present, create the missing tracking and persist it + GithubWebhookTracking updatedTracking; + if (tracking.isEmpty()) { + updatedTracking = new GithubWebhookTracking(); + updatedTracking.setInstallationId(installationId); + updatedTracking.setLastUpdated(DateTimeHelper.now()); + updatedTracking.setPullRequestNumber(request.getNumber()); + updatedTracking.setRepositoryFullName(fullRepoName); + updatedTracking.setNeedsRevalidation(false); + updatedTracking.setManualRevalidationCount(0); + if (!"open".equalsIgnoreCase(request.getState())) { + LOGGER.warn( + "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice", + updatedTracking.getPullRequestNumber(), fullRepoName); + } + } else { + // uses the DB version as a base if available + updatedTracking = tracking.get(); + } + // always update the head SHA and the last known state + updatedTracking.setLastKnownState(request.getState()); + updatedTracking.setHeadSha(request.getHead().getSha()); + + // save the data, and log on its success or failure + List<GithubWebhookTracking> savedTracking = + dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), + Arrays.asList(updatedTracking)); + if (savedTracking.isEmpty()) { + LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", + fullRepoName, request.getNumber()); + return null; + } + // return the updated tracking when successful + LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName, + request.getNumber()); + return savedTracking.get(0); + } + + /** + * Attempts to retrieve a webhook tracking record given the installation, repository, and pull + * request number. + * + * @param installationId the installation ID for the ECA app in the given repository + * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty + * @param pullRequestNumber the pull request number that is being processed currently + * @return the webhook tracking record if it can be found, or an empty optional. + */ + public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, + String installationId, String orgName, String repositoryName, int pullRequestNumber) { + checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); + String repositoryFullName = getFullRepoName(orgName, repositoryName); + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId); + params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName); + params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber)); + return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)) + .stream().findFirst(); + } + + /** + * Using the configured GitHub application JWT and application ID, fetches all active + * installations and updates the DB cache containing the installation data. After all records are + * updated, the starting timestamp is used to delete stale records that were updated before the + * starting time. + * + * This does not use locking, but with the way that updates are done to look for long stale + * entries, multiple concurrent runs would not lead to a loss of data/integrity. + */ + public void updateAppInstallationRecords() { + // get the installations for the currently configured app + List<GithubApplicationInstallationData> installations = + middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt())); + // check that there are installations, none may indicate an issue, and better to fail + // pessimistically + if (installations.isEmpty()) { + LOGGER.warn("Did not find any installations for the currently configured Github application"); + return; + } + // trace log the installations for more context + LOGGER.debug("Found {} installations to cache", installations.size()); + + // create a common timestamp that looks for entries stale for more than a day + Date startingTimestamp = + new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli()); + // from installations, build records and start the processing for each entry + List<GithubApplicationInstallation> installationRecords = + installations.stream().map(this::processInstallation).toList(); + + // once records are prepared, persist them back to the database with updates where necessary as + // a batch + RequestWrapper wrap = new FlatRequestWrapper( + URI.create("https://api.eclipse.org/git/webhooks/github/installations")); + List<GithubApplicationInstallation> repoRecords = + dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), + installationRecords); + if (repoRecords.size() != installationRecords.size()) { + LOGGER.warn( + "Background update to installation records had a size mismatch, cleaning will be skipped for this run"); + return; } - /** - * Validates required fields for processing requests. - * - * @param installationId the installation ID for the ECA app in the given repository - * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty - * @param pullRequestNumber the pull request number that is being processed currently - * @throws BadRequestException if at least one of the parameters is in an invalid state. - */ - private void checkRequestParameters(String installationId, String org, String repoName, int pullRequestNumber) { - List<String> missingFields = new ArrayList<>(); - if (StringUtils.isBlank(installationId)) { - missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW); - } - if (StringUtils.isBlank(org)) { - missingFields.add(GitEcaParameterNames.ORG_NAME_RAW); - } - if (StringUtils.isBlank(repoName)) { - missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW); - } - if (pullRequestNumber < 1) { - missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW); - } - - // throw exception if some fields are missing as we can't continue to process the request - if (!missingFields.isEmpty()) { - throw new BadRequestException("Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' ')); - } + // build query to do cleanup of stale records + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + params.add(GitEcaParameterNames.APPLICATION_ID_RAW, + Integer.toString(webhooksConfig.github().appId())); + params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, + DateTimeHelper.toRFC3339(startingTimestamp)); + + // run the delete call, removing stale entries + dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); + } + + /** + * Simple helper method so we don't have to repeat static strings in multiple places. + * + * @param fullRepoName the full repo name including org for the target PR. + * @return the full repo URL on Github for the request + */ + public static String getRepoUrl(String org, String repoName) { + return "https://github.com/" + org + '/' + repoName; + } + + /** + * Sends off a POST to update the commit status given a context for the current PR. + * + * @param request the current webhook update request + * @param state the state to set the status to + * @param fingerprint the internal unique string for the set of commits being processed + */ + private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) { + PullRequest pr = request.getPullRequest(); + if (pr == null) { + // should not be reachable, but for safety + throw new IllegalStateException("Pull request should not be null when handling validation"); } - /** - * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching. - * Checks database for existing record, and returns record with a touched date for existing entries. - * - * @param ghInstallation raw Github installation record for current application - * @return the new or updated installation record to be persisted to the database. - */ - private GithubApplicationInstallation processInstallation(GithubApplicationInstallationData ghInstallation) { - RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations")); - // build the lookup query for the current installation record - MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId())); - params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId())); - - // lookup existing records in the database - List<GithubApplicationInstallation> existingRecords = dao - .get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); - - // check for existing entry, creating if missing - GithubApplicationInstallation installation; - if (existingRecords == null || existingRecords.isEmpty()) { - installation = new GithubApplicationInstallation(); - installation.setAppId(webhooksConfig.github().appId()); - installation.setInstallationId(ghInstallation.getId()); - } else { - installation = existingRecords.get(0); - } - // update the basic stats to handle renames, and update last updated time - // login is technically nullable, so this might be missing. This is best we can do, as we can't look up by id - installation - .setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) ? ghInstallation.getAccount().getLogin() - : "UNKNOWN"); - installation.setLastUpdated(new Date()); - return installation; + LOGGER.trace("Generated access token for installation {}: {}", + request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, + request.getRepository().getOwner().getLogin(), request.getRepository().getName(), + pr.getHead().getSha(), + GithubCommitStatusRequest.builder().setDescription(state.getMessage()) + .setState(state.toString()) + .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" + + request.getRepository().getFullName() + '/' + pr.getNumber()) + .setContext(webhooksConfig.github().context()).build()); + } + + /** + * Wraps a nullable value fetch to handle errors and will return null if the value can't be + * retrieved. + * + * @param supplier the method with potentially nullable values + * @return the value if it can be found, or null + */ + private String getNullableString(Supplier<String> supplier) { + try { + return supplier.get(); + } catch (NullPointerException e) { + // suppress, as we don't care at this point if its null + } + return null; + } + + /** + * Validates required fields for processing requests. + * + * @param installationId the installation ID for the ECA app in the given repository + * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty + * @param pullRequestNumber the pull request number that is being processed currently + * @throws BadRequestException if at least one of the parameters is in an invalid state. + */ + private void checkRequestParameters(String installationId, String org, String repoName, + int pullRequestNumber) { + List<String> missingFields = new ArrayList<>(); + if (StringUtils.isBlank(installationId)) { + missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW); + } + if (StringUtils.isBlank(org)) { + missingFields.add(GitEcaParameterNames.ORG_NAME_RAW); + } + if (StringUtils.isBlank(repoName)) { + missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW); + } + if (pullRequestNumber < 1) { + missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW); } - /** - * Retrieves the full repo name for a given org and repo name, used for storage and legacy support. - * - * @param org organization name being targeted - * @param repo name of repo being targeted - * @return the full repo name, following format of org/repo - */ - public static String getFullRepoName(String org, String repo) { - return TransformationHelper.formatLog(org + '/' + repo); + // throw exception if some fields are missing as we can't continue to process the request + if (!missingFields.isEmpty()) { + throw new BadRequestException( + "Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' ')); + } + } + + /** + * Converts the raw installation data from Github into a short record to be persisted to database + * as a form of persistent caching. Checks database for existing record, and returns record with a + * touched date for existing entries. + * + * @param ghInstallation raw Github installation record for current application + * @return the new or updated installation record to be persisted to the database. + */ + private GithubApplicationInstallation processInstallation( + GithubApplicationInstallationData ghInstallation) { + RequestWrapper wrap = new FlatRequestWrapper( + URI.create("https://api.eclipse.org/git/webhooks/github/installations")); + // build the lookup query for the current installation record + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + params.add(GitEcaParameterNames.APPLICATION_ID_RAW, + Integer.toString(webhooksConfig.github().appId())); + params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId())); + + // lookup existing records in the database + List<GithubApplicationInstallation> existingRecords = + dao.get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); + + // check for existing entry, creating if missing + GithubApplicationInstallation installation; + if (existingRecords == null || existingRecords.isEmpty()) { + installation = new GithubApplicationInstallation(); + installation.setAppId(webhooksConfig.github().appId()); + installation.setInstallationId(ghInstallation.getId()); + } else { + installation = existingRecords.get(0); } + // update the basic stats to handle renames, and update last updated time + // login is technically nullable, so this might be missing. This is best we can do, as we can't + // look up by id + installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) + ? ghInstallation.getAccount().getLogin() + : "UNKNOWN"); + installation.setLastUpdated(new Date()); + return installation; + } + + /** + * Retrieves the full repo name for a given org and repo name, used for storage and legacy + * support. + * + * @param org organization name being targeted + * @param repo name of repo being targeted + * @return the full repo name, following format of org/repo + */ + public static String getFullRepoName(String org, String repo) { + return TransformationHelper.formatLog(org + '/' + repo); + } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java index 95801ddf..de91b46e 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java +++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java @@ -26,7 +26,6 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.google.auto.value.AutoValue; -import com.google.auto.value.extension.memoized.Memoized; /** * Represents an internal response for a call to this API. @@ -35,7 +34,7 @@ import com.google.auto.value.extension.memoized.Memoized; */ @AutoValue @JsonNaming(LowerCamelCaseStrategy.class) -@JsonDeserialize(builder = $AutoValue_ValidationResponse.Builder.class) +@JsonDeserialize(builder = AutoValue_ValidationResponse.Builder.class) public abstract class ValidationResponse { public static final String NIL_HASH_PLACEHOLDER = "_nil"; @@ -54,7 +53,6 @@ public abstract class ValidationResponse { return getErrorCount() <= 0; } - @Memoized public int getErrorCount() { return getCommits().values().stream().mapToInt(s -> s.getErrors().size()).sum(); } diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java new file mode 100644 index 00000000..10c3000a --- /dev/null +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -0,0 +1,133 @@ +package org.eclipsefoundation.git.eca.helper; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.eclipsefoundation.git.eca.api.GithubAPI; +import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; +import org.eclipsefoundation.git.eca.model.Commit; +import org.eclipsefoundation.git.eca.model.CommitStatus; +import org.eclipsefoundation.git.eca.model.GitUser; +import org.eclipsefoundation.git.eca.model.ValidationRequest; +import org.eclipsefoundation.git.eca.model.ValidationResponse; +import org.eclipsefoundation.git.eca.namespace.APIStatusCode; +import org.eclipsefoundation.git.eca.namespace.ProviderType; +import org.eclipsefoundation.git.eca.service.ValidationService; +import org.eclipsefoundation.http.model.FlatRequestWrapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectSpy; +import jakarta.inject.Inject; + +@QuarkusTest +class GithubHelperTest { + + @Inject + GithubHelper helper; + + @RestClient + @InjectSpy + GithubAPI ghApi; + + @InjectMock + ValidationService validationService; + + @Test + void testHandleGithubWebhookValidation_WithErrors() { + String commitHash = "abc123"; + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + GithubWebhookRequest request = + createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash); + Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com"); + + ValidationRequest vr = ValidationRequest.builder() + .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) + .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit)).build(); + + ValidationResponse mockResponse = + createErrorValidationResponse(commitHash, "Missing ECA for user"); + + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) + .thenReturn(mockResponse); + + helper.handleGithubWebhookValidation(request, vr, null); + + String expectedComment = + "The following errors were found in the validation of this pull request:\n" + + "- Missing ECA for user\n" + + "Please check the ECA validation status for more information."; + + verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), + Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedComment)); + } + + /** + * Creates a GitHub webhook request for testing purposes. + * + * @param installationId The installation ID + * @param repoName The repository name + * @param orgName The organization name + * @param prNumber The pull request number + * @param commitSha The commit SHA + * @return GithubWebhookRequest instance + */ + private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, + String orgName, int prNumber, String commitSha) { + return GithubWebhookRequest.builder() + .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build()) + .setRepository(GithubWebhookRequest.Repository.builder().setName(repoName) + .setFullName(orgName + "/" + repoName) + .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build()) + .setHtmlUrl("https://github.com/" + orgName + "/" + repoName).build()) + .setPullRequest( + GithubWebhookRequest.PullRequest.builder().setNumber(prNumber).setState("open") + .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build()) + .build()) + .build(); + } + + /** + * Creates a test commit with specified user details. + * + * @param commitHash The commit hash + * @param userName The user's name + * @param userEmail The user's email + * @return Commit instance + */ + private Commit createTestCommit(String commitHash, String userName, String userEmail) { + GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build(); + return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser) + .setSubject("Test commit").setBody("Test commit body").setParents(Collections.emptyList()) + .build(); + } + + /** + * Creates a validation response with an error status. + * + * @param commitHash The commit hash to associate the error with + * @param errorMessage The error message + * @return ValidationResponse instance + */ + private ValidationResponse createErrorValidationResponse(String commitHash, String errorMessage) { + Map<String, CommitStatus> commits = new HashMap<>(); + CommitStatus status = CommitStatus.builder().build(); + status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR); + commits.put(commitHash, status); + + return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits) + .setTrackedProject(true).setStrictMode(true).build(); + } + +} diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 7031f2d0..0da330ce 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -1,14 +1,13 @@ /********************************************************************* -* Copyright (c) 2023 Eclipse Foundation. -* -* This program and the accompanying materials are made -* available under the terms of the Eclipse Public License 2.0 -* which is available at https://www.eclipse.org/legal/epl-2.0/ -* -* Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org> -* -* SPDX-License-Identifier: EPL-2.0 -**********************************************************************/ + * Copyright (c) 2023 Eclipse Foundation. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org> + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ package org.eclipsefoundation.git.eca.test.api; import java.time.LocalDateTime; @@ -41,73 +40,75 @@ import io.quarkus.test.Mock; @ApplicationScoped public class MockGithubAPI implements GithubAPI { - Map<String, Map<Integer, List<GithubCommit>>> commits; - Map<String, Map<String, String>> commitStatuses; + Map<String, Map<Integer, List<GithubCommit>>> commits; + Map<String, Map<String, String>> commitStatuses; - public MockGithubAPI() { - this.commitStatuses = new HashMap<>(); - this.commits = new HashMap<>(); - this.commits - .put("eclipsefdn/sample", - Map - .of(42, Arrays - .asList(GithubCommit - .builder() - .setSha("sha-1234") - .setAuthor(GithubCommitUser.builder().setLogin("testuser").build()) - .setCommitter(GithubCommitUser.builder().setLogin("testuser").build()) - .setParents(Collections.emptyList()) - .setCommit(CommitData - .builder() - .setAuthor(GitCommitUser - .builder() - .setName("The Wizard") - .setEmail("code.wiz@important.co") - .build()) - .setCommitter(GitCommitUser - .builder() - .setName("The Wizard") - .setEmail("code.wiz@important.co") - .build()) - .build()) - .build()))); - } + public MockGithubAPI() { + this.commitStatuses = new HashMap<>(); + this.commits = new HashMap<>(); + this.commits.put("eclipsefdn/sample", + Map.of(42, + Arrays.asList(GithubCommit.builder().setSha("sha-1234") + .setAuthor(GithubCommitUser.builder().setLogin("testuser").build()) + .setCommitter(GithubCommitUser.builder().setLogin("testuser").build()) + .setParents(Collections.emptyList()) + .setCommit(CommitData.builder() + .setAuthor(GitCommitUser.builder().setName("The Wizard") + .setEmail("code.wiz@important.co").build()) + .setCommitter(GitCommitUser.builder().setName("The Wizard") + .setEmail("code.wiz@important.co").build()) + .build()) + .build()))); + } - @Override - public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, int pullNumber) { - return PullRequest.builder().build(); - } + @Override + public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, + int pullNumber) { + return PullRequest.builder().build(); + } - @Override - public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, String repo, int pullNumber) { - String repoFullName = org + '/' + repo; - List<GithubCommit> results = commits.get(repoFullName).get(pullNumber); - if (results == null || !results.isEmpty()) { - return RestResponse.status(404); - } - return RestResponse.ok(results); + @Override + public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, + String repo, int pullNumber) { + String repoFullName = org + '/' + repo; + List<GithubCommit> results = commits.get(repoFullName).get(pullNumber); + if (results == null || !results.isEmpty()) { + return RestResponse.status(404); } + return RestResponse.ok(results); + } - @Override - public Response updateStatus(String bearer, String apiVersion, String org, String repo, String prHeadSha, - GithubCommitStatusRequest commitStatusUpdate) { - String repoFullName = org + '/' + repo; - commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, commitStatusUpdate.getState(), (k, v) -> v); - return Response.ok().build(); - } + @Override + public Response updateStatus(String bearer, String apiVersion, String org, String repo, + String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) { + String repoFullName = org + '/' + repo; + commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, + commitStatusUpdate.getState(), (k, v) -> v); + return Response.ok().build(); + } - @Override - public RestResponse<List<GithubApplicationInstallationData>> getInstallations(BaseAPIParameters params, String bearer) { - throw new UnsupportedOperationException("Unimplemented method 'getInstallations'"); - } + @Override + public Response addComment(String bearer, String apiVersion, String organization, String repo, + int pullNumber, String comment) { + return Response.ok().build(); + } - @Override - public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, String installationId) { - return GithubAccessToken.builder().setToken("gh-token-" + installationId).setExpiresAt(LocalDateTime.now().plusHours(1L)).build(); - } + @Override + public RestResponse<List<GithubApplicationInstallationData>> getInstallations( + BaseAPIParameters params, String bearer) { + throw new UnsupportedOperationException("Unimplemented method 'getInstallations'"); + } + + @Override + public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, + String installationId) { + return GithubAccessToken.builder().setToken("gh-token-" + installationId) + .setExpiresAt(LocalDateTime.now().plusHours(1L)).build(); + } + + @Override + public Response getInstallationRepositories(BaseAPIParameters params, String bearer) { + throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'"); + } - @Override - public Response getInstallationRepositories(BaseAPIParameters params, String bearer) { - throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'"); - } } -- GitLab From 86e8b328921c7c5aa5ff0effbf33ae5d887bbdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Tue, 22 Apr 2025 17:02:51 +0200 Subject: [PATCH 02/14] feat: using proper body structure against github API --- .../org/eclipsefoundation/git/eca/api/GithubAPI.java | 8 +++++--- .../git/eca/helper/GithubHelper.java | 7 ++++++- .../git/eca/helper/GithubHelperTest.java | 11 ++++++----- .../git/eca/test/api/MockGithubAPI.java | 3 ++- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index bced49eb..d6bcc0f4 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -19,6 +19,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; import org.jboss.resteasy.reactive.RestResponse; import jakarta.ws.rs.BeanParam; @@ -92,21 +93,22 @@ public interface GithubAPI { GithubCommitStatusRequest commitStatusUpdate); /** - * Adds a comment to a pull request. + * Adds a review comment to a pull request. * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param organization the organization that owns the targeted repo * @param repo the repo name that is being targeted * @param pullNumber the number of the pull request to which the comment will be added. - * @param comment the content of the comment to add. + * @param commentRequest the review comment request containing the comment details * @return response indicating the result of the operation. */ @POST @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, - @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, String comment); + @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, + GithubPullRequestCommentRequest commentRequest); /** * Requires a JWT bearer token for the application to retrieve installations for. Returns a list diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 3a744772..aafdd909 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -30,6 +30,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.eclipsefoundation.git.eca.config.WebhooksConfig; @@ -262,7 +263,11 @@ public class GithubHelper { ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, request.getRepository().getOwner().getLogin(), request.getRepository().getName(), - request.getPullRequest().getNumber(), sb.toString()); + request.getPullRequest().getNumber(), GithubPullRequestCommentRequest.builder() + .setBody(sb.toString()) + .setCommitId(request.getPullRequest().getHead().getSha()) + .setPath(".") + .build()); } /** diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 10c3000a..4ea43028 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -11,6 +11,7 @@ import java.util.Map; import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipsefoundation.git.eca.api.GithubAPI; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.model.Commit; import org.eclipsefoundation.git.eca.model.CommitStatus; @@ -20,7 +21,6 @@ import org.eclipsefoundation.git.eca.model.ValidationResponse; import org.eclipsefoundation.git.eca.namespace.APIStatusCode; import org.eclipsefoundation.git.eca.namespace.ProviderType; import org.eclipsefoundation.git.eca.service.ValidationService; -import org.eclipsefoundation.http.model.FlatRequestWrapper; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import io.quarkus.test.InjectMock; @@ -64,13 +64,14 @@ class GithubHelperTest { helper.handleGithubWebhookValidation(request, vr, null); - String expectedComment = - "The following errors were found in the validation of this pull request:\n" + var expectedRequestBody = GithubPullRequestCommentRequest.builder() + .setBody("The following errors were found in the validation of this pull request:\n" + "- Missing ECA for user\n" - + "Please check the ECA validation status for more information."; + + "Please check the ECA validation status for more information.") + .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build(); verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), - Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedComment)); + Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody)); } /** diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 0da330ce..82eb09a2 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -30,6 +30,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.jboss.resteasy.reactive.RestResponse; @@ -89,7 +90,7 @@ public class MockGithubAPI implements GithubAPI { @Override public Response addComment(String bearer, String apiVersion, String organization, String repo, - int pullNumber, String comment) { + int pullNumber, GithubPullRequestCommentRequest comment) { return Response.ok().build(); } -- GitLab From 1b371e6b58bd0e382827696a9f81da764575b0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Wed, 23 Apr 2025 08:54:10 +0200 Subject: [PATCH 03/14] chore: fix formatting --- .../git/eca/api/GithubAPI.java | 207 ++-- .../GithubPullRequestCommentRequest.java | 64 ++ .../git/eca/helper/GithubHelper.java | 1011 +++++++++-------- .../git/eca/model/ValidationResponse.java | 35 +- .../git/eca/helper/GithubHelperTest.java | 204 ++-- .../git/eca/test/api/MockGithubAPI.java | 122 +- 6 files changed, 868 insertions(+), 775 deletions(-) create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index d6bcc0f4..74e36207 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -42,112 +42,117 @@ import jakarta.ws.rs.core.Response; @Produces("application/json") public interface GithubAPI { - /** - * Retrieves information about a certain pull request in a repo if it exists - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param repoFull the full repo name that is being targeted - * @param pullNumber the pull request number - * @return information about the given pull request if it exists. - */ - @GET - @Path("repos/{org}/{repo}/pulls/{pullNumber}") - public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, - @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber); + /** + * Retrieves information about a certain pull request in a repo if it exists + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param repoFull the full repo name that is being targeted + * @param pullNumber the pull request number + * @return information about the given pull request if it exists. + */ + @GET + @Path("repos/{org}/{repo}/pulls/{pullNumber}") + public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, + @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("pullNumber") int pullNumber); - /** - * Retrieves a list of commits related to the given pull request. - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param repo the repo name that is being targeted - * @param pullNumber the pull request number - * @return list of commits associated with the pull request, wrapped in a jax-rs response - */ - @GET - @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits") - public RestResponse<List<GithubCommit>> getCommits( - @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, - @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber); + /** + * Retrieves a list of commits related to the given pull request. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param repo the repo name that is being targeted + * @param pullNumber the pull request number + * @return list of commits associated with the pull request, wrapped in a jax-rs response + */ + @GET + @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits") + public RestResponse<List<GithubCommit>> getCommits( + @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, + @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("pullNumber") int pullNumber); - /** - * Posts an update to the Github API, using an access token to update a given pull requests commit - * status, targeted using the head sha. - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param organization the organization that owns the targeted repo - * @param repo the repo name that is being targeted - * @param prHeadSha the head SHA for the request - * @param commitStatusUpdate the status body sent with the request - * @return JAX-RS response to check for success or failure based on status code. - */ - @POST - @Path("repos/{org}/{repo}/statuses/{prHeadSha}") - public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, - @PathParam("repo") String repo, @PathParam("prHeadSha") String prHeadSha, - GithubCommitStatusRequest commitStatusUpdate); + /** + * Posts an update to the Github API, using an access token to update a given pull requests + * commit status, targeted using the head sha. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param prHeadSha the head SHA for the request + * @param commitStatusUpdate the status body sent with the request + * @return JAX-RS response to check for success or failure based on status code. + */ + @POST + @Path("repos/{org}/{repo}/statuses/{prHeadSha}") + public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, + @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate); - /** - * Adds a review comment to a pull request. - * - * @param bearer authorization header value, access token for application with access to repo - * @param apiVersion the version of the GH API to target when making the request - * @param organization the organization that owns the targeted repo - * @param repo the repo name that is being targeted - * @param pullNumber the number of the pull request to which the comment will be added. - * @param commentRequest the review comment request containing the comment details - * @return response indicating the result of the operation. - */ - @POST - @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") - public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, - @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, - GithubPullRequestCommentRequest commentRequest); + /** + * Adds a review comment to a pull request. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param pullNumber the number of the pull request to which the comment will be added. + * @param commentRequest the review comment request containing the comment details + * @return response indicating the result of the operation. + */ + @POST + @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") + public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, + @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("pullNumber") int pullNumber, + GithubPullRequestCommentRequest commentRequest); - /** - * Requires a JWT bearer token for the application to retrieve installations for. Returns a list - * of installations for the given application. - * - * @param params the general params for requests, including pagination - * @param bearer JWT bearer token for the target application - * @return list of installations for the application - */ - @GET - @Path("app/installations") - public RestResponse<List<GithubApplicationInstallationData>> getInstallations( - @BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); + /** + * Requires a JWT bearer token for the application to retrieve installations for. Returns a list + * of installations for the given application. + * + * @param params the general params for requests, including pagination + * @param bearer JWT bearer token for the target application + * @return list of installations for the application + */ + @GET + @Path("app/installations") + public RestResponse<List<GithubApplicationInstallationData>> getInstallations( + @BeanParam BaseAPIParameters params, + @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); - /** - * Retrieves an access token for a specific installation, given the applications JWT bearer and - * the api version. - * - * @param bearer the authorization header value, should contain the apps signed JWT token - * @param apiVersion the API version to target with the request - * @param installationId the installation to generate an access token for - * @return the Github access token for the GH app installation - */ - @POST - @Path("app/installations/{installationId}/access_tokens") - public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, - @PathParam("installationId") String installationId); + /** + * Retrieves an access token for a specific installation, given the applications JWT bearer and + * the api version. + * + * @param bearer the authorization header value, should contain the apps signed JWT token + * @param apiVersion the API version to target with the request + * @param installationId the installation to generate an access token for + * @return the Github access token for the GH app installation + */ + @POST + @Path("app/installations/{installationId}/access_tokens") + public GithubAccessToken getNewAccessToken( + @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, + @PathParam("installationId") String installationId); - /** - * Returns a list of repositories for the given installation. - * - * @param params the general params for requests, including pagination - * @param bearer JWT bearer token for the target installation - * @return list of repositories for the installation as a response for pagination - */ - @GET - @Path("installation/repositories") - public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, - @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); + /** + * Returns a list of repositories for the given installation. + * + * @param params the general params for requests, including pagination + * @param bearer JWT bearer token for the target installation + * @return list of repositories for the installation as a response for pagination + */ + @GET + @Path("installation/repositories") + public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, + @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); } diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java new file mode 100644 index 00000000..50b4a9d3 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.git.eca.api.models; + +import jakarta.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.google.auto.value.AutoValue; + +/** + * Model for creating a review comment on a pull request. + */ +@AutoValue +@JsonDeserialize(builder = AutoValue_GithubPullRequestCommentRequest.Builder.class) +public abstract class GithubPullRequestCommentRequest { + public abstract String getBody(); + + public abstract String getCommitId(); + + public abstract String getPath(); + + @Nullable + public abstract Integer getLine(); + + @Nullable + public abstract String getSide(); + + @Nullable + public abstract Integer getStartLine(); + + @Nullable + public abstract String getStartSide(); + + public static Builder builder() { + return new AutoValue_GithubPullRequestCommentRequest.Builder(); + } + + @AutoValue.Builder + @JsonPOJOBuilder(withPrefix = "set") + public abstract static class Builder { + public abstract Builder setBody(String body); + + public abstract Builder setCommitId(String commitId); + + public abstract Builder setPath(String path); + + public abstract Builder setLine(@Nullable Integer line); + + public abstract Builder setSide(@Nullable String side); + + public abstract Builder setStartLine(@Nullable Integer startLine); + + public abstract Builder setStartSide(@Nullable String startSide); + + public abstract GithubPullRequestCommentRequest build(); + } +} diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index aafdd909..8f37d74f 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -72,524 +72,543 @@ import jakarta.ws.rs.core.Response; */ @ApplicationScoped public class GithubHelper { - private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class); - - private static final String VALIDATION_LOGGING_MESSAGE = - "Setting validation state for {}/#{} to {}"; - - @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") - String apiVersion; - - @Inject - WebhooksConfig webhooksConfig; - - @Inject - JwtHelper jwtHelper; - @Inject - APIMiddleware middleware; - @Inject - PersistenceDao dao; - @Inject - FilterService filters; - @Inject - JwtHelper jwt; - - @Inject - ValidationService validation; - @Inject - ValidationStatusService validationStatus; - @Inject - GithubApplicationService ghAppService; - - @RestClient - GithubAPI ghApi; - - /** - * Using the wrapper and the passed unique information about a Github validation instance, lookup - * or create the tracking request and validate the data. - * - * @param wrapper the wrapper for the current request - * @param org the name of the GH organization - * @param repoName the slug of the repo that has the PR to be validated - * @param prNo the PR number for the current request. - * @param forceRevalidation true if revalidation should be forced when there is no changes, false - * otherwise. - * @return the validated response if it is a valid request, or throws a web exception if there is - * a problem validating the request. - */ - public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, - String repoName, Integer prNo, boolean forceRevalidation) { - // use logging helper to sanitize newlines as they aren't needed here - String fullRepoName = getFullRepoName(org, repoName); - // get the installation ID for the given repo if it exists, and if the PR noted exists - String installationId = ghAppService.getInstallationForRepo(org, repoName); - Optional<PullRequest> prResponse = - ghAppService.getPullRequest(installationId, org, repoName, prNo); - if (StringUtils.isBlank(installationId)) { - throw new BadRequestException( - "Could not find an ECA app installation for repo name: " + fullRepoName); - } else if (prResponse.isEmpty()) { - throw new NotFoundException( - String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName)); + private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class); + + private static final String VALIDATION_LOGGING_MESSAGE = + "Setting validation state for {}/#{} to {}"; + + @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") + String apiVersion; + + @Inject + WebhooksConfig webhooksConfig; + + @Inject + JwtHelper jwtHelper; + @Inject + APIMiddleware middleware; + @Inject + PersistenceDao dao; + @Inject + FilterService filters; + @Inject + JwtHelper jwt; + + @Inject + ValidationService validation; + @Inject + ValidationStatusService validationStatus; + @Inject + GithubApplicationService ghAppService; + + @RestClient + GithubAPI ghApi; + + /** + * Using the wrapper and the passed unique information about a Github validation instance, + * lookup or create the tracking request and validate the data. + * + * @param wrapper the wrapper for the current request + * @param org the name of the GH organization + * @param repoName the slug of the repo that has the PR to be validated + * @param prNo the PR number for the current request. + * @param forceRevalidation true if revalidation should be forced when there is no changes, + * false otherwise. + * @return the validated response if it is a valid request, or throws a web exception if there + * is a problem validating the request. + */ + public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, + String repoName, Integer prNo, boolean forceRevalidation) { + // use logging helper to sanitize newlines as they aren't needed here + String fullRepoName = getFullRepoName(org, repoName); + // get the installation ID for the given repo if it exists, and if the PR noted exists + String installationId = ghAppService.getInstallationForRepo(org, repoName); + Optional<PullRequest> prResponse = + ghAppService.getPullRequest(installationId, org, repoName, prNo); + if (StringUtils.isBlank(installationId)) { + throw new BadRequestException( + "Could not find an ECA app installation for repo name: " + fullRepoName); + } else if (prResponse.isEmpty()) { + throw new NotFoundException( + String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName)); + } + + // prepare the request for consumption + String repoUrl = getRepoUrl(org, repoName); + ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl); + + // build the commit sha list based on the prepared request + List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList(); + // there should always be commits for a PR, but in case, lets check + if (commitShas.isEmpty()) { + throw new BadRequestException( + String.format("Could not find any commits for %s#%d", fullRepoName, prNo)); + } + LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); + + // retrieve the webhook tracking info, or generate an entry to track this PR if it's + // missing. + GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, + installationId, org, repoName, prResponse.get()); + if (updatedTracking == null) { + throw new ServerErrorException( + "Error while attempting to revalidate request, try again later.", + Response.Status.INTERNAL_SERVER_ERROR); + } + + // get the commit status of commits to use for checking historic validation + List<CommitValidationStatus> statuses = + validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas); + if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) { + throw new BadRequestException( + "Cannot find validation history for current non-open PR, cannot provide validation status"); + } + + // we only want to update/revalidate for open PRs, so don't do this check if the PR is + // merged/closed + if (forceRevalidation) { + LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo); + return handleGithubWebhookValidation( + GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); + } else if ("open".equalsIgnoreCase(prResponse.get().getState()) + && commitShas.size() != statuses.size()) { + LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", + fullRepoName, prNo); + // using the updated tracking, perform the validation + return handleGithubWebhookValidation( + GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); + } + return null; } - // prepare the request for consumption - String repoUrl = getRepoUrl(org, repoName); - ValidationRequest vr = generateRequest(installationId, org, repoName, prNo, repoUrl); - - // build the commit sha list based on the prepared request - List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList(); - // there should always be commits for a PR, but in case, lets check - if (commitShas.isEmpty()) { - throw new BadRequestException( - String.format("Could not find any commits for %s#%d", fullRepoName, prNo)); - } - LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); - - // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing. - GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, - installationId, org, repoName, prResponse.get()); - if (updatedTracking == null) { - throw new ServerErrorException( - "Error while attempting to revalidate request, try again later.", - Response.Status.INTERNAL_SERVER_ERROR); + /** + * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from + * the noted pull request using the installation ID for access/authorization. + * + * @param installationId the ECA app installation ID for the organization + * @param repositoryFullName the full name of the repository where the PR resides + * @param pullRequestNumber the pull request number that is being validated + * @param repositoryUrl the URL of the repository that contains the commits to validate + * @return the populated validation request for the Github request information + */ + @SuppressWarnings("null") + public ValidationRequest generateRequest(String installationId, String orgName, + String repositoryName, int pullRequestNumber, String repositoryUrl) { + checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); + // get the commits that will be validated, don't cache as changes can come in too fast for + // it to + // be useful + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, + TransformationHelper.formatLog(orgName), + TransformationHelper.formatLog(repositoryName)); + } + List<GithubCommit> commits = + middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId), + apiVersion, orgName, repositoryName, pullRequestNumber)); + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(), + pullRequestNumber, TransformationHelper.formatLog(orgName), + TransformationHelper.formatLog(repositoryName)); + } + + // set up the validation request from current data + return ValidationRequest.builder().setProvider(ProviderType.GITHUB) + .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true) + .setCommits(commits.stream() + .map(c -> Commit.builder().setHash(c.getSha()).setAuthor(GitUser.builder() + .setMail(getNullableString( + () -> c.getCommit().getAuthor().getEmail())) + .setName(getNullableString( + () -> c.getCommit().getAuthor().getName())) + .setExternalId(getNullableString(() -> c.getAuthor().getLogin())) + .build()) + .setCommitter(GitUser.builder() + .setMail(getNullableString( + () -> c.getCommit().getCommitter().getEmail())) + .setName(getNullableString( + () -> c.getCommit().getCommitter().getName())) + .setExternalId(getNullableString( + () -> c.getCommitter().getLogin())) + .build()) + .setParents( + c.getParents().stream().map(ParentCommit::getSha).toList()) + .build()) + .toList()) + .build(); } - // get the commit status of commits to use for checking historic validation - List<CommitValidationStatus> statuses = - validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas); - if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) { - throw new BadRequestException( - "Cannot find validation history for current non-open PR, cannot provide validation status"); + /** + * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As + * commits need to pass branch rules including the existing ECA check, we don't need to check + * the commits here. + * + * @param request information about the request from the GH webhook on what resource requested + * revalidation. Used to target the commit status of the resources + */ + public void sendMergeQueueStatus(GithubWebhookRequest request) { + if (request.getMergeGroup() == null) { + throw new BadRequestException( + "Merge group object required in webhook request to send status for merge queue"); + } + // send the success status for the head SHA + ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), + apiVersion, request.getRepository().getOwner().getLogin(), + request.getRepository().getName(), request.getMergeGroup().getHeadSha(), + GithubCommitStatusRequest.builder().setDescription( + "Commits in merge group should be previously validated, auto-passing HEAD commit") + .setState(GithubCommitStatuses.SUCCESS.toString()) + .setContext(webhooksConfig.github().context()).build()); } - // we only want to update/revalidate for open PRs, so don't do this check if the PR is - // merged/closed - if (forceRevalidation) { - LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo); - return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), - vr, wrapper); - } else if ("open".equalsIgnoreCase(prResponse.get().getState()) - && commitShas.size() != statuses.size()) { - LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", - fullRepoName, prNo); - // using the updated tracking, perform the validation - return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), - vr, wrapper); - } - return null; - } - - /** - * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the - * noted pull request using the installation ID for access/authorization. - * - * @param installationId the ECA app installation ID for the organization - * @param repositoryFullName the full name of the repository where the PR resides - * @param pullRequestNumber the pull request number that is being validated - * @param repositoryUrl the URL of the repository that contains the commits to validate - * @return the populated validation request for the Github request information - */ - @SuppressWarnings("null") - public ValidationRequest generateRequest(String installationId, String orgName, - String repositoryName, int pullRequestNumber, String repositoryUrl) { - checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); - // get the commits that will be validated, don't cache as changes can come in too fast for it to - // be useful - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, - TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName)); - } - List<GithubCommit> commits = - middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId), - apiVersion, orgName, repositoryName, pullRequestNumber)); - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber, - TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName)); + private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) { + if (errors.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append("The following errors were found in the validation of this pull request:\n"); + for (String error : errors) { + sb.append("- ").append(error).append('\n'); + } + sb.append("Please check the ECA validation status for more information."); + + ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, + request.getRepository().getOwner().getLogin(), request.getRepository().getName(), + request.getPullRequest().getNumber(), + GithubPullRequestCommentRequest.builder().setBody(sb.toString()) + .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".") + .build()); } - // set up the validation request from current data - return ValidationRequest.builder().setProvider(ProviderType.GITHUB) - .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true) - .setCommits(commits.stream() - .map(c -> Commit.builder().setHash(c.getSha()) - .setAuthor(GitUser.builder() - .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail())) - .setName(getNullableString(() -> c.getCommit().getAuthor().getName())) - .setExternalId(getNullableString(() -> c.getAuthor().getLogin())).build()) - .setCommitter(GitUser.builder() - .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail())) - .setName(getNullableString(() -> c.getCommit().getCommitter().getName())) - .setExternalId(getNullableString(() -> c.getCommitter().getLogin())).build()) - .setParents(c.getParents().stream().map(ParentCommit::getSha).toList()).build()) - .toList()) - .build(); - } - - /** - * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As - * commits need to pass branch rules including the existing ECA check, we don't need to check the - * commits here. - * - * @param request information about the request from the GH webhook on what resource requested - * revalidation. Used to target the commit status of the resources - */ - public void sendMergeQueueStatus(GithubWebhookRequest request) { - if (request.getMergeGroup() == null) { - throw new BadRequestException( - "Merge group object required in webhook request to send status for merge queue"); - } - // send the success status for the head SHA - ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, - request.getRepository().getOwner().getLogin(), request.getRepository().getName(), - request.getMergeGroup().getHeadSha(), - GithubCommitStatusRequest.builder() - .setDescription( - "Commits in merge group should be previously validated, auto-passing HEAD commit") - .setState(GithubCommitStatuses.SUCCESS.toString()) - .setContext(webhooksConfig.github().context()).build()); - } - - private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) { - if (errors.isEmpty()) { - return; - } - StringBuilder sb = new StringBuilder(); - sb.append("The following errors were found in the validation of this pull request:\n"); - for (String error : errors) { - sb.append("- ").append(error).append('\n'); - } - sb.append("Please check the ECA validation status for more information."); - - ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, - request.getRepository().getOwner().getLogin(), request.getRepository().getName(), - request.getPullRequest().getNumber(), GithubPullRequestCommentRequest.builder() - .setBody(sb.toString()) - .setCommitId(request.getPullRequest().getHead().getSha()) - .setPath(".") - .build()); - } - - /** - * Process the current request and update the checks state to pending then success or failure. - * Contains verbose TRACE logging for more info on the states of the validation for more - * information - * - * @param request information about the request from the GH webhook on what resource requested - * revalidation. Used to target the commit status of the resources - * @param vr the pseudo request generated from the contextual webhook data. Used to make use of - * existing validation logic. - * @return true if the validation passed, false otherwise. - */ - public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, - ValidationRequest vr, RequestWrapper wrapper) { - // null check the pull request to make sure that someone didn't push a bad value - PullRequest pr = request.getPullRequest(); - if (pr == null) { - throw new IllegalStateException("Pull request should not be null when handling validation"); + /** + * Process the current request and update the checks state to pending then success or failure. + * Contains verbose TRACE logging for more info on the states of the validation for more + * information + * + * @param request information about the request from the GH webhook on what resource requested + * revalidation. Used to target the commit status of the resources + * @param vr the pseudo request generated from the contextual webhook data. Used to make use of + * existing validation logic. + * @return true if the validation passed, false otherwise. + */ + public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, + ValidationRequest vr, RequestWrapper wrapper) { + // null check the pull request to make sure that someone didn't push a bad value + PullRequest pr = request.getPullRequest(); + if (pr == null) { + throw new IllegalStateException( + "Pull request should not be null when handling validation"); + } + + // update the status before processing + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), + pr.getNumber(), GithubCommitStatuses.PENDING); + updateCommitStatus(request, GithubCommitStatuses.PENDING); + + // validate the response + LOGGER.trace("Begining validation of request for {}/#{}", + request.getRepository().getFullName(), pr.getNumber()); + ValidationResponse r = validation.validateIncomingRequest(vr, wrapper); + if (r.getPassed()) { + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), + pr.getNumber(), GithubCommitStatuses.SUCCESS); + updateCommitStatus(request, GithubCommitStatuses.SUCCESS); + } else { + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), + pr.getNumber(), GithubCommitStatuses.FAILURE); + updateCommitStatus(request, GithubCommitStatuses.FAILURE); + commentOnFailure(request, + r.getCommits().values().stream().flatMap(c -> c.getErrors().stream()) + .map(e -> e.getMessage()).collect(Collectors.toSet())); + } + return r; } - // update the status before processing - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), - GithubCommitStatuses.PENDING); - updateCommitStatus(request, GithubCommitStatuses.PENDING); - - // validate the response - LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(), - pr.getNumber()); - ValidationResponse r = validation.validateIncomingRequest(vr, wrapper); - if (r.getPassed()) { - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), - pr.getNumber(), GithubCommitStatuses.SUCCESS); - updateCommitStatus(request, GithubCommitStatuses.SUCCESS); - } else { - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), - pr.getNumber(), GithubCommitStatuses.FAILURE); - updateCommitStatus(request, GithubCommitStatuses.FAILURE); - commentOnFailure(request, - r.getCommits().values().stream().flatMap(c -> c.getErrors().stream()) - .map(e -> e.getMessage()).collect(Collectors.toSet())); - } - return r; - } - - /** - * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, - * and will update the state of existing requests as well. - * - * @param installationId the installation ID for the ECA app in the given repository - * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty - * @param pr the pull request targeted by the validation request. - * @return a new or updated tracking object, or null if there was an error in saving the - * information - */ - public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, - String installationId, String orgName, String repositoryName, PullRequest pr) { - return updateGithubTrackingIfMissing(wrapper, getExistingRequestInformation(wrapper, - installationId, orgName, repositoryName, pr.getNumber()), pr, installationId, orgName, - repositoryName); - } - - /** - * Checks if the Github tracking is present for the current request, and if missing will generate - * a new record and save it. - * - * @param tracking the optional tracking entry for the current request - * @param request the pull request that is being validated - * @param installationId the ECA app installation ID for the current request - * @param fullRepoName the full repo name for the validation request - */ - public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, - Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId, - String orgName, String repositoryName) { - String fullRepoName = getFullRepoName(orgName, repositoryName); - // if there is no tracking present, create the missing tracking and persist it - GithubWebhookTracking updatedTracking; - if (tracking.isEmpty()) { - updatedTracking = new GithubWebhookTracking(); - updatedTracking.setInstallationId(installationId); - updatedTracking.setLastUpdated(DateTimeHelper.now()); - updatedTracking.setPullRequestNumber(request.getNumber()); - updatedTracking.setRepositoryFullName(fullRepoName); - updatedTracking.setNeedsRevalidation(false); - updatedTracking.setManualRevalidationCount(0); - if (!"open".equalsIgnoreCase(request.getState())) { - LOGGER.warn( - "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice", - updatedTracking.getPullRequestNumber(), fullRepoName); - } - } else { - // uses the DB version as a base if available - updatedTracking = tracking.get(); + /** + * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, + * and will update the state of existing requests as well. + * + * @param installationId the installation ID for the ECA app in the given repository + * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty + * @param pr the pull request targeted by the validation request. + * @return a new or updated tracking object, or null if there was an error in saving the + * information + */ + public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, + String installationId, String orgName, String repositoryName, PullRequest pr) { + return updateGithubTrackingIfMissing( + wrapper, getExistingRequestInformation(wrapper, installationId, orgName, + repositoryName, pr.getNumber()), + pr, installationId, orgName, repositoryName); } - // always update the head SHA and the last known state - updatedTracking.setLastKnownState(request.getState()); - updatedTracking.setHeadSha(request.getHead().getSha()); - - // save the data, and log on its success or failure - List<GithubWebhookTracking> savedTracking = - dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), - Arrays.asList(updatedTracking)); - if (savedTracking.isEmpty()) { - LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", - fullRepoName, request.getNumber()); - return null; - } - // return the updated tracking when successful - LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName, - request.getNumber()); - return savedTracking.get(0); - } - - /** - * Attempts to retrieve a webhook tracking record given the installation, repository, and pull - * request number. - * - * @param installationId the installation ID for the ECA app in the given repository - * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty - * @param pullRequestNumber the pull request number that is being processed currently - * @return the webhook tracking record if it can be found, or an empty optional. - */ - public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, - String installationId, String orgName, String repositoryName, int pullRequestNumber) { - checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); - String repositoryFullName = getFullRepoName(orgName, repositoryName); - MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId); - params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName); - params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber)); - return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)) - .stream().findFirst(); - } - - /** - * Using the configured GitHub application JWT and application ID, fetches all active - * installations and updates the DB cache containing the installation data. After all records are - * updated, the starting timestamp is used to delete stale records that were updated before the - * starting time. - * - * This does not use locking, but with the way that updates are done to look for long stale - * entries, multiple concurrent runs would not lead to a loss of data/integrity. - */ - public void updateAppInstallationRecords() { - // get the installations for the currently configured app - List<GithubApplicationInstallationData> installations = - middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt())); - // check that there are installations, none may indicate an issue, and better to fail - // pessimistically - if (installations.isEmpty()) { - LOGGER.warn("Did not find any installations for the currently configured Github application"); - return; - } - // trace log the installations for more context - LOGGER.debug("Found {} installations to cache", installations.size()); - - // create a common timestamp that looks for entries stale for more than a day - Date startingTimestamp = - new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli()); - // from installations, build records and start the processing for each entry - List<GithubApplicationInstallation> installationRecords = - installations.stream().map(this::processInstallation).toList(); - - // once records are prepared, persist them back to the database with updates where necessary as - // a batch - RequestWrapper wrap = new FlatRequestWrapper( - URI.create("https://api.eclipse.org/git/webhooks/github/installations")); - List<GithubApplicationInstallation> repoRecords = - dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), - installationRecords); - if (repoRecords.size() != installationRecords.size()) { - LOGGER.warn( - "Background update to installation records had a size mismatch, cleaning will be skipped for this run"); - return; + + /** + * Checks if the Github tracking is present for the current request, and if missing will + * generate a new record and save it. + * + * @param tracking the optional tracking entry for the current request + * @param request the pull request that is being validated + * @param installationId the ECA app installation ID for the current request + * @param fullRepoName the full repo name for the validation request + */ + public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, + Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId, + String orgName, String repositoryName) { + String fullRepoName = getFullRepoName(orgName, repositoryName); + // if there is no tracking present, create the missing tracking and persist it + GithubWebhookTracking updatedTracking; + if (tracking.isEmpty()) { + updatedTracking = new GithubWebhookTracking(); + updatedTracking.setInstallationId(installationId); + updatedTracking.setLastUpdated(DateTimeHelper.now()); + updatedTracking.setPullRequestNumber(request.getNumber()); + updatedTracking.setRepositoryFullName(fullRepoName); + updatedTracking.setNeedsRevalidation(false); + updatedTracking.setManualRevalidationCount(0); + if (!"open".equalsIgnoreCase(request.getState())) { + LOGGER.warn( + "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice", + updatedTracking.getPullRequestNumber(), fullRepoName); + } + } else { + // uses the DB version as a base if available + updatedTracking = tracking.get(); + } + // always update the head SHA and the last known state + updatedTracking.setLastKnownState(request.getState()); + updatedTracking.setHeadSha(request.getHead().getSha()); + + // save the data, and log on its success or failure + List<GithubWebhookTracking> savedTracking = + dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), + Arrays.asList(updatedTracking)); + if (savedTracking.isEmpty()) { + LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", + fullRepoName, request.getNumber()); + return null; + } + // return the updated tracking when successful + LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", + fullRepoName, request.getNumber()); + return savedTracking.get(0); } - // build query to do cleanup of stale records - MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.APPLICATION_ID_RAW, - Integer.toString(webhooksConfig.github().appId())); - params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, - DateTimeHelper.toRFC3339(startingTimestamp)); - - // run the delete call, removing stale entries - dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); - } - - /** - * Simple helper method so we don't have to repeat static strings in multiple places. - * - * @param fullRepoName the full repo name including org for the target PR. - * @return the full repo URL on Github for the request - */ - public static String getRepoUrl(String org, String repoName) { - return "https://github.com/" + org + '/' + repoName; - } - - /** - * Sends off a POST to update the commit status given a context for the current PR. - * - * @param request the current webhook update request - * @param state the state to set the status to - * @param fingerprint the internal unique string for the set of commits being processed - */ - private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) { - PullRequest pr = request.getPullRequest(); - if (pr == null) { - // should not be reachable, but for safety - throw new IllegalStateException("Pull request should not be null when handling validation"); + /** + * Attempts to retrieve a webhook tracking record given the installation, repository, and pull + * request number. + * + * @param installationId the installation ID for the ECA app in the given repository + * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty + * @param pullRequestNumber the pull request number that is being processed currently + * @return the webhook tracking record if it can be found, or an empty optional. + */ + public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, + String installationId, String orgName, String repositoryName, int pullRequestNumber) { + checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); + String repositoryFullName = getFullRepoName(orgName, repositoryName); + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId); + params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName); + params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, + Integer.toString(pullRequestNumber)); + return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)) + .stream().findFirst(); } - LOGGER.trace("Generated access token for installation {}: {}", - request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); - ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, - request.getRepository().getOwner().getLogin(), request.getRepository().getName(), - pr.getHead().getSha(), - GithubCommitStatusRequest.builder().setDescription(state.getMessage()) - .setState(state.toString()) - .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" - + request.getRepository().getFullName() + '/' + pr.getNumber()) - .setContext(webhooksConfig.github().context()).build()); - } - - /** - * Wraps a nullable value fetch to handle errors and will return null if the value can't be - * retrieved. - * - * @param supplier the method with potentially nullable values - * @return the value if it can be found, or null - */ - private String getNullableString(Supplier<String> supplier) { - try { - return supplier.get(); - } catch (NullPointerException e) { - // suppress, as we don't care at this point if its null + /** + * Using the configured GitHub application JWT and application ID, fetches all active + * installations and updates the DB cache containing the installation data. After all records + * are updated, the starting timestamp is used to delete stale records that were updated before + * the starting time. + * + * This does not use locking, but with the way that updates are done to look for long stale + * entries, multiple concurrent runs would not lead to a loss of data/integrity. + */ + public void updateAppInstallationRecords() { + // get the installations for the currently configured app + List<GithubApplicationInstallationData> installations = + middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt())); + // check that there are installations, none may indicate an issue, and better to fail + // pessimistically + if (installations.isEmpty()) { + LOGGER.warn( + "Did not find any installations for the currently configured Github application"); + return; + } + // trace log the installations for more context + LOGGER.debug("Found {} installations to cache", installations.size()); + + // create a common timestamp that looks for entries stale for more than a day + Date startingTimestamp = + new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli()); + // from installations, build records and start the processing for each entry + List<GithubApplicationInstallation> installationRecords = + installations.stream().map(this::processInstallation).toList(); + + // once records are prepared, persist them back to the database with updates where necessary + // as + // a batch + RequestWrapper wrap = new FlatRequestWrapper( + URI.create("https://api.eclipse.org/git/webhooks/github/installations")); + List<GithubApplicationInstallation> repoRecords = + dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), + installationRecords); + if (repoRecords.size() != installationRecords.size()) { + LOGGER.warn( + "Background update to installation records had a size mismatch, cleaning will be skipped for this run"); + return; + } + + // build query to do cleanup of stale records + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + params.add(GitEcaParameterNames.APPLICATION_ID_RAW, + Integer.toString(webhooksConfig.github().appId())); + params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, + DateTimeHelper.toRFC3339(startingTimestamp)); + + // run the delete call, removing stale entries + dao.delete( + new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); } - return null; - } - - /** - * Validates required fields for processing requests. - * - * @param installationId the installation ID for the ECA app in the given repository - * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty - * @param pullRequestNumber the pull request number that is being processed currently - * @throws BadRequestException if at least one of the parameters is in an invalid state. - */ - private void checkRequestParameters(String installationId, String org, String repoName, - int pullRequestNumber) { - List<String> missingFields = new ArrayList<>(); - if (StringUtils.isBlank(installationId)) { - missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW); + + /** + * Simple helper method so we don't have to repeat static strings in multiple places. + * + * @param fullRepoName the full repo name including org for the target PR. + * @return the full repo URL on Github for the request + */ + public static String getRepoUrl(String org, String repoName) { + return "https://github.com/" + org + '/' + repoName; } - if (StringUtils.isBlank(org)) { - missingFields.add(GitEcaParameterNames.ORG_NAME_RAW); + + /** + * Sends off a POST to update the commit status given a context for the current PR. + * + * @param request the current webhook update request + * @param state the state to set the status to + * @param fingerprint the internal unique string for the set of commits being processed + */ + private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state) { + PullRequest pr = request.getPullRequest(); + if (pr == null) { + // should not be reachable, but for safety + throw new IllegalStateException( + "Pull request should not be null when handling validation"); + } + + LOGGER.trace("Generated access token for installation {}: {}", + request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), + apiVersion, request.getRepository().getOwner().getLogin(), + request.getRepository().getName(), pr.getHead().getSha(), + GithubCommitStatusRequest.builder().setDescription(state.getMessage()) + .setState(state.toString()) + .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" + + request.getRepository().getFullName() + '/' + pr.getNumber()) + .setContext(webhooksConfig.github().context()).build()); } - if (StringUtils.isBlank(repoName)) { - missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW); + + /** + * Wraps a nullable value fetch to handle errors and will return null if the value can't be + * retrieved. + * + * @param supplier the method with potentially nullable values + * @return the value if it can be found, or null + */ + private String getNullableString(Supplier<String> supplier) { + try { + return supplier.get(); + } catch (NullPointerException e) { + // suppress, as we don't care at this point if its null + } + return null; } - if (pullRequestNumber < 1) { - missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW); + + /** + * Validates required fields for processing requests. + * + * @param installationId the installation ID for the ECA app in the given repository + * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty + * @param pullRequestNumber the pull request number that is being processed currently + * @throws BadRequestException if at least one of the parameters is in an invalid state. + */ + private void checkRequestParameters(String installationId, String org, String repoName, + int pullRequestNumber) { + List<String> missingFields = new ArrayList<>(); + if (StringUtils.isBlank(installationId)) { + missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW); + } + if (StringUtils.isBlank(org)) { + missingFields.add(GitEcaParameterNames.ORG_NAME_RAW); + } + if (StringUtils.isBlank(repoName)) { + missingFields.add(GitEcaParameterNames.REPOSITORY_NAME_RAW); + } + if (pullRequestNumber < 1) { + missingFields.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW); + } + + // throw exception if some fields are missing as we can't continue to process the request + if (!missingFields.isEmpty()) { + throw new BadRequestException("Missing fields in order to prepare request: " + + StringUtils.join(missingFields, ' ')); + } } - // throw exception if some fields are missing as we can't continue to process the request - if (!missingFields.isEmpty()) { - throw new BadRequestException( - "Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' ')); + /** + * Converts the raw installation data from Github into a short record to be persisted to + * database as a form of persistent caching. Checks database for existing record, and returns + * record with a touched date for existing entries. + * + * @param ghInstallation raw Github installation record for current application + * @return the new or updated installation record to be persisted to the database. + */ + private GithubApplicationInstallation processInstallation( + GithubApplicationInstallationData ghInstallation) { + RequestWrapper wrap = new FlatRequestWrapper( + URI.create("https://api.eclipse.org/git/webhooks/github/installations")); + // build the lookup query for the current installation record + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + params.add(GitEcaParameterNames.APPLICATION_ID_RAW, + Integer.toString(webhooksConfig.github().appId())); + params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, + Integer.toString(ghInstallation.getId())); + + // lookup existing records in the database + List<GithubApplicationInstallation> existingRecords = dao.get( + new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); + + // check for existing entry, creating if missing + GithubApplicationInstallation installation; + if (existingRecords == null || existingRecords.isEmpty()) { + installation = new GithubApplicationInstallation(); + installation.setAppId(webhooksConfig.github().appId()); + installation.setInstallationId(ghInstallation.getId()); + } else { + installation = existingRecords.get(0); + } + // update the basic stats to handle renames, and update last updated time + // login is technically nullable, so this might be missing. This is best we can do, as we + // can't + // look up by id + installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) + ? ghInstallation.getAccount().getLogin() + : "UNKNOWN"); + installation.setLastUpdated(new Date()); + return installation; } - } - - /** - * Converts the raw installation data from Github into a short record to be persisted to database - * as a form of persistent caching. Checks database for existing record, and returns record with a - * touched date for existing entries. - * - * @param ghInstallation raw Github installation record for current application - * @return the new or updated installation record to be persisted to the database. - */ - private GithubApplicationInstallation processInstallation( - GithubApplicationInstallationData ghInstallation) { - RequestWrapper wrap = new FlatRequestWrapper( - URI.create("https://api.eclipse.org/git/webhooks/github/installations")); - // build the lookup query for the current installation record - MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.APPLICATION_ID_RAW, - Integer.toString(webhooksConfig.github().appId())); - params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId())); - - // lookup existing records in the database - List<GithubApplicationInstallation> existingRecords = - dao.get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); - - // check for existing entry, creating if missing - GithubApplicationInstallation installation; - if (existingRecords == null || existingRecords.isEmpty()) { - installation = new GithubApplicationInstallation(); - installation.setAppId(webhooksConfig.github().appId()); - installation.setInstallationId(ghInstallation.getId()); - } else { - installation = existingRecords.get(0); + + /** + * Retrieves the full repo name for a given org and repo name, used for storage and legacy + * support. + * + * @param org organization name being targeted + * @param repo name of repo being targeted + * @return the full repo name, following format of org/repo + */ + public static String getFullRepoName(String org, String repo) { + return TransformationHelper.formatLog(org + '/' + repo); } - // update the basic stats to handle renames, and update last updated time - // login is technically nullable, so this might be missing. This is best we can do, as we can't - // look up by id - installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) - ? ghInstallation.getAccount().getLogin() - : "UNKNOWN"); - installation.setLastUpdated(new Date()); - return installation; - } - - /** - * Retrieves the full repo name for a given org and repo name, used for storage and legacy - * support. - * - * @param org organization name being targeted - * @param repo name of repo being targeted - * @return the full repo name, following format of org/repo - */ - public static String getFullRepoName(String org, String repo) { - return TransformationHelper.formatLog(org + '/' + repo); - } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java index de91b46e..2a5ea7a3 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java +++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java @@ -1,14 +1,13 @@ /********************************************************************* -* Copyright (c) 2020 Eclipse Foundation. -* -* This program and the accompanying materials are made -* available under the terms of the Eclipse Public License 2.0 -* which is available at https://www.eclipse.org/legal/epl-2.0/ -* -* Author: Martin Lowe <martin.lowe@eclipse-foundation.org> -* -* SPDX-License-Identifier: EPL-2.0 -**********************************************************************/ + * Copyright (c) 2020 Eclipse Foundation. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ package org.eclipsefoundation.git.eca.model; import java.time.ZonedDateTime; @@ -60,9 +59,11 @@ public abstract class ValidationResponse { /** @param error message to add to the API response */ public void addError(String hash, String error, APIStatusCode code) { if (this.getTrackedProject() || this.getStrictMode()) { - getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addError(error, code); + getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()) + .addError(error, code); } else { - getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addWarning(error, code); + getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()) + .addWarning(error, code); } } @@ -73,7 +74,8 @@ public abstract class ValidationResponse { /** * Converts the APIResponse to a web response with appropriate status. * - * @return a web response with status {@link Status.OK} if the commits pass validation, {@link Status.FORBIDDEN} otherwise. + * @return a web response with status {@link Status.OK} if the commits pass validation, + * {@link Status.FORBIDDEN} otherwise. */ public Response toResponse() { // update error count before returning @@ -85,11 +87,8 @@ public abstract class ValidationResponse { } public static Builder builder() { - return new AutoValue_ValidationResponse.Builder() - .setStrictMode(false) - .setTrackedProject(false) - .setTime(ZonedDateTime.now()) - .setCommits(new HashMap<>()); + return new AutoValue_ValidationResponse.Builder().setStrictMode(false) + .setTrackedProject(false).setTime(ZonedDateTime.now()).setCommits(new HashMap<>()); } @AutoValue.Builder diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 4ea43028..d3785ed8 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -31,104 +31,110 @@ import jakarta.inject.Inject; @QuarkusTest class GithubHelperTest { - @Inject - GithubHelper helper; - - @RestClient - @InjectSpy - GithubAPI ghApi; - - @InjectMock - ValidationService validationService; - - @Test - void testHandleGithubWebhookValidation_WithErrors() { - String commitHash = "abc123"; - String orgName = "test-org"; - String repoName = "test-repo"; - int prNumber = 42; - - GithubWebhookRequest request = - createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash); - Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com"); - - ValidationRequest vr = ValidationRequest.builder() - .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) - .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit)).build(); - - ValidationResponse mockResponse = - createErrorValidationResponse(commitHash, "Missing ECA for user"); - - when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) - .thenReturn(mockResponse); - - helper.handleGithubWebhookValidation(request, vr, null); - - var expectedRequestBody = GithubPullRequestCommentRequest.builder() - .setBody("The following errors were found in the validation of this pull request:\n" - + "- Missing ECA for user\n" - + "Please check the ECA validation status for more information.") - .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build(); - - verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), - Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody)); - } - - /** - * Creates a GitHub webhook request for testing purposes. - * - * @param installationId The installation ID - * @param repoName The repository name - * @param orgName The organization name - * @param prNumber The pull request number - * @param commitSha The commit SHA - * @return GithubWebhookRequest instance - */ - private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, - String orgName, int prNumber, String commitSha) { - return GithubWebhookRequest.builder() - .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build()) - .setRepository(GithubWebhookRequest.Repository.builder().setName(repoName) - .setFullName(orgName + "/" + repoName) - .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build()) - .setHtmlUrl("https://github.com/" + orgName + "/" + repoName).build()) - .setPullRequest( - GithubWebhookRequest.PullRequest.builder().setNumber(prNumber).setState("open") - .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build()) - .build()) - .build(); - } - - /** - * Creates a test commit with specified user details. - * - * @param commitHash The commit hash - * @param userName The user's name - * @param userEmail The user's email - * @return Commit instance - */ - private Commit createTestCommit(String commitHash, String userName, String userEmail) { - GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build(); - return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser) - .setSubject("Test commit").setBody("Test commit body").setParents(Collections.emptyList()) - .build(); - } - - /** - * Creates a validation response with an error status. - * - * @param commitHash The commit hash to associate the error with - * @param errorMessage The error message - * @return ValidationResponse instance - */ - private ValidationResponse createErrorValidationResponse(String commitHash, String errorMessage) { - Map<String, CommitStatus> commits = new HashMap<>(); - CommitStatus status = CommitStatus.builder().build(); - status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR); - commits.put(commitHash, status); - - return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits) - .setTrackedProject(true).setStrictMode(true).build(); - } + @Inject + GithubHelper helper; + + @RestClient + @InjectSpy + GithubAPI ghApi; + + @InjectMock + ValidationService validationService; + + @Test + void testHandleGithubWebhookValidation_WithErrors() { + String commitHash = "abc123"; + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + GithubWebhookRequest request = + createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash); + Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com"); + + ValidationRequest vr = ValidationRequest.builder() + .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) + .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit)) + .build(); + + ValidationResponse mockResponse = + createErrorValidationResponse(commitHash, "Missing ECA for user"); + + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) + .thenReturn(mockResponse); + + helper.handleGithubWebhookValidation(request, vr, null); + + var expectedRequestBody = GithubPullRequestCommentRequest.builder() + .setBody("The following errors were found in the validation of this pull request:\n" + + "- Missing ECA for user\n" + + "Please check the ECA validation status for more information.") + .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build(); + + verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), + Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody)); + } + + /** + * Creates a GitHub webhook request for testing purposes. + * + * @param installationId The installation ID + * @param repoName The repository name + * @param orgName The organization name + * @param prNumber The pull request number + * @param commitSha The commit SHA + * @return GithubWebhookRequest instance + */ + private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, + String orgName, int prNumber, String commitSha) { + return GithubWebhookRequest.builder() + .setInstallation( + GithubWebhookRequest.Installation.builder().setId(installationId).build()) + .setRepository( + GithubWebhookRequest.Repository.builder().setName(repoName) + .setFullName(orgName + "/" + repoName) + .setOwner(GithubWebhookRequest.RepositoryOwner.builder() + .setLogin(orgName).build()) + .setHtmlUrl("https://github.com/" + orgName + "/" + repoName) + .build()) + .setPullRequest(GithubWebhookRequest.PullRequest.builder().setNumber(prNumber) + .setState("open").setHead(GithubWebhookRequest.PullRequestHead.builder() + .setSha(commitSha).build()) + .build()) + .build(); + } + + /** + * Creates a test commit with specified user details. + * + * @param commitHash The commit hash + * @param userName The user's name + * @param userEmail The user's email + * @return Commit instance + */ + private Commit createTestCommit(String commitHash, String userName, String userEmail) { + GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build(); + return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser) + .setSubject("Test commit").setBody("Test commit body") + .setParents(Collections.emptyList()).build(); + } + + /** + * Creates a validation response with an error status. + * + * @param commitHash The commit hash to associate the error with + * @param errorMessage The error message + * @return ValidationResponse instance + */ + private ValidationResponse createErrorValidationResponse(String commitHash, + String errorMessage) { + Map<String, CommitStatus> commits = new HashMap<>(); + CommitStatus status = CommitStatus.builder().build(); + status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR); + commits.put(commitHash, status); + + return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits) + .setTrackedProject(true).setStrictMode(true).build(); + } } diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 82eb09a2..5f146677 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -41,75 +41,75 @@ import io.quarkus.test.Mock; @ApplicationScoped public class MockGithubAPI implements GithubAPI { - Map<String, Map<Integer, List<GithubCommit>>> commits; - Map<String, Map<String, String>> commitStatuses; + Map<String, Map<Integer, List<GithubCommit>>> commits; + Map<String, Map<String, String>> commitStatuses; - public MockGithubAPI() { - this.commitStatuses = new HashMap<>(); - this.commits = new HashMap<>(); - this.commits.put("eclipsefdn/sample", - Map.of(42, - Arrays.asList(GithubCommit.builder().setSha("sha-1234") - .setAuthor(GithubCommitUser.builder().setLogin("testuser").build()) - .setCommitter(GithubCommitUser.builder().setLogin("testuser").build()) - .setParents(Collections.emptyList()) - .setCommit(CommitData.builder() - .setAuthor(GitCommitUser.builder().setName("The Wizard") - .setEmail("code.wiz@important.co").build()) - .setCommitter(GitCommitUser.builder().setName("The Wizard") - .setEmail("code.wiz@important.co").build()) - .build()) - .build()))); - } + public MockGithubAPI() { + this.commitStatuses = new HashMap<>(); + this.commits = new HashMap<>(); + this.commits.put("eclipsefdn/sample", + Map.of(42, Arrays.asList(GithubCommit.builder().setSha("sha-1234") + .setAuthor(GithubCommitUser.builder().setLogin("testuser").build()) + .setCommitter(GithubCommitUser.builder().setLogin("testuser").build()) + .setParents(Collections.emptyList()) + .setCommit(CommitData.builder() + .setAuthor(GitCommitUser.builder().setName("The Wizard") + .setEmail("code.wiz@important.co").build()) + .setCommitter(GitCommitUser.builder().setName("The Wizard") + .setEmail("code.wiz@important.co").build()) + .build()) + .build()))); + } - @Override - public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, - int pullNumber) { - return PullRequest.builder().build(); - } + @Override + public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, + int pullNumber) { + return PullRequest.builder().build(); + } - @Override - public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, - String repo, int pullNumber) { - String repoFullName = org + '/' + repo; - List<GithubCommit> results = commits.get(repoFullName).get(pullNumber); - if (results == null || !results.isEmpty()) { - return RestResponse.status(404); + @Override + public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, + String repo, int pullNumber) { + String repoFullName = org + '/' + repo; + List<GithubCommit> results = commits.get(repoFullName).get(pullNumber); + if (results == null || !results.isEmpty()) { + return RestResponse.status(404); + } + return RestResponse.ok(results); } - return RestResponse.ok(results); - } - @Override - public Response updateStatus(String bearer, String apiVersion, String org, String repo, - String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) { - String repoFullName = org + '/' + repo; - commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, - commitStatusUpdate.getState(), (k, v) -> v); - return Response.ok().build(); - } + @Override + public Response updateStatus(String bearer, String apiVersion, String org, String repo, + String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) { + String repoFullName = org + '/' + repo; + commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, + commitStatusUpdate.getState(), (k, v) -> v); + return Response.ok().build(); + } - @Override - public Response addComment(String bearer, String apiVersion, String organization, String repo, - int pullNumber, GithubPullRequestCommentRequest comment) { - return Response.ok().build(); - } + @Override + public Response addComment(String bearer, String apiVersion, String organization, String repo, + int pullNumber, GithubPullRequestCommentRequest comment) { + return Response.ok().build(); + } - @Override - public RestResponse<List<GithubApplicationInstallationData>> getInstallations( - BaseAPIParameters params, String bearer) { - throw new UnsupportedOperationException("Unimplemented method 'getInstallations'"); - } + @Override + public RestResponse<List<GithubApplicationInstallationData>> getInstallations( + BaseAPIParameters params, String bearer) { + throw new UnsupportedOperationException("Unimplemented method 'getInstallations'"); + } - @Override - public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, - String installationId) { - return GithubAccessToken.builder().setToken("gh-token-" + installationId) - .setExpiresAt(LocalDateTime.now().plusHours(1L)).build(); - } + @Override + public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, + String installationId) { + return GithubAccessToken.builder().setToken("gh-token-" + installationId) + .setExpiresAt(LocalDateTime.now().plusHours(1L)).build(); + } - @Override - public Response getInstallationRepositories(BaseAPIParameters params, String bearer) { - throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'"); - } + @Override + public Response getInstallationRepositories(BaseAPIParameters params, String bearer) { + throw new UnsupportedOperationException( + "Unimplemented method 'getInstallationRepositories'"); + } } -- GitLab From b732bb984a66c88975465b1e9c7bd208c6a24e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Wed, 23 Apr 2025 10:48:09 +0200 Subject: [PATCH 04/14] chore: adding missing license header --- .../git/eca/helper/GithubHelperTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index d3785ed8..9f7ee052 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -1,3 +1,12 @@ +/* + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + package org.eclipsefoundation.git.eca.helper; import static org.mockito.Mockito.verify; -- GitLab From cb9c94edce5d8d7d7a883bbc8a5cdf74a76e2edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Wed, 23 Apr 2025 11:26:24 +0200 Subject: [PATCH 05/14] chore: adding missing JavaDoc --- .../org/eclipsefoundation/git/eca/helper/GithubHelper.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 8f37d74f..d0471054 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -261,6 +261,13 @@ public class GithubHelper { .setContext(webhooksConfig.github().context()).build()); } + /** + * Posts a comment on a GitHub pull request when validation errors are found. + * If the error set is empty, no comment will be posted. + * + * @param request The GitHub webhook request containing pull request and repository information + * @param errors A set of error messages found during validation + */ private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) { if (errors.isEmpty()) { return; -- GitLab From 1f720f825ba41a7af1e6a52e553a98f6c61bdc44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Wed, 23 Apr 2025 12:29:49 +0200 Subject: [PATCH 06/14] feat: using Qute to create a more flexible message template --- .../git/eca/helper/GithubHelper.java | 95 +++++++++++----- .../templates/github/validation_failure.md | 19 ++++ .../git/eca/helper/GithubHelperTest.java | 105 +++++++++++++++--- 3 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 src/main/resources/templates/github/validation_failure.md diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index d0471054..d8f7c901 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -17,6 +17,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Map.Entry; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Supplier; @@ -38,6 +40,7 @@ import org.eclipsefoundation.git.eca.dto.CommitValidationStatus; import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation; import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking; import org.eclipsefoundation.git.eca.model.Commit; +import org.eclipsefoundation.git.eca.model.CommitStatus; import org.eclipsefoundation.git.eca.model.GitUser; import org.eclipsefoundation.git.eca.model.ValidationRequest; import org.eclipsefoundation.git.eca.model.ValidationResponse; @@ -56,7 +59,8 @@ import org.eclipsefoundation.utils.helper.DateTimeHelper; import org.eclipsefoundation.utils.helper.TransformationHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; @@ -104,6 +108,9 @@ public class GithubHelper { @RestClient GithubAPI ghApi; + @Location("github/validation_failure.md") + Template failureMessage; + /** * Using the wrapper and the passed unique information about a Github validation instance, * lookup or create the tracking request and validate the data. @@ -261,32 +268,6 @@ public class GithubHelper { .setContext(webhooksConfig.github().context()).build()); } - /** - * Posts a comment on a GitHub pull request when validation errors are found. - * If the error set is empty, no comment will be posted. - * - * @param request The GitHub webhook request containing pull request and repository information - * @param errors A set of error messages found during validation - */ - private void commentOnFailure(GithubWebhookRequest request, Set<String> errors) { - if (errors.isEmpty()) { - return; - } - StringBuilder sb = new StringBuilder(); - sb.append("The following errors were found in the validation of this pull request:\n"); - for (String error : errors) { - sb.append("- ").append(error).append('\n'); - } - sb.append("Please check the ECA validation status for more information."); - - ghApi.addComment(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, - request.getRepository().getOwner().getLogin(), request.getRepository().getName(), - request.getPullRequest().getNumber(), - GithubPullRequestCommentRequest.builder().setBody(sb.toString()) - .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".") - .build()); - } - /** * Process the current request and update the checks state to pending then success or failure. * Contains verbose TRACE logging for more info on the states of the validation for more @@ -325,12 +306,30 @@ public class GithubHelper { pr.getNumber(), GithubCommitStatuses.FAILURE); updateCommitStatus(request, GithubCommitStatuses.FAILURE); commentOnFailure(request, + r.getCommits().entrySet().stream() + .filter(e -> !e.getValue().getErrors().isEmpty()).map(e -> e.getKey()) + .map(hash -> findCommitByHash(vr, hash)).filter(Optional::isPresent) + .map(Optional::get).map(c -> c.getAuthor().getName()) + .collect(Collectors.toSet()), r.getCommits().values().stream().flatMap(c -> c.getErrors().stream()) .map(e -> e.getMessage()).collect(Collectors.toSet())); } return r; } + /** + * Searches for a commit in the validation request based on its hash value. + * + * @param vr The validation request containing the list of commits to search + * @param hash The hash value to search for + * @return An Optional containing the matching Commit if found, or empty if not found + */ + private Optional<Commit> findCommitByHash(ValidationRequest vr, String hash) { + return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash())) + .findFirst(); + } + + /** * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, * and will update the state of existing requests as well. @@ -516,6 +515,48 @@ public class GithubHelper { .setContext(webhooksConfig.github().context()).build()); } + /** + * This method posts a comment to the pull request detailing the validation errors and + * mentioning the usernames that need to take action. + * + * @param request The request containing repository and pull request information + * @param usernames Set of GitHub usernames that need to take action + * @param errors Set of error messages describing the validation failures + * + * @throws IllegalArgumentException if the request parameter is null + */ + private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, + Set<String> errors) { + // This should never happen given the logic behind the getPassed, but adding this just in + // case that logic changes + if (errors.isEmpty()) { + return; + } + + String ghBearerString = jwtHelper.getGhBearerString(request.getInstallation().getId()); + String login = request.getRepository().getOwner().getLogin(); + String repositoryName = request.getRepository().getName(); + Integer pullRequestNumber = request.getPullRequest().getNumber(); + + GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest.builder() + .setBody(failureMessage.data("reasons", errors).data("usernames", usernames) + .render()) + .setCommitId(Objects.requireNonNull(request, "Request cannot be null") + .getPullRequest().getHead().getSha()) + .setPath(".").build(); + + LOGGER.trace("Generated access token for installation {}: {}", + request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + + LOGGER.trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber, + TransformationHelper.formatLog(login), + TransformationHelper.formatLog(repositoryName), comment.getBody()); + + ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, + comment); + } + /** * Wraps a nullable value fetch to handle errors and will return null if the value can't be * retrieved. diff --git a/src/main/resources/templates/github/validation_failure.md b/src/main/resources/templates/github/validation_failure.md new file mode 100644 index 00000000..67f9243a --- /dev/null +++ b/src/main/resources/templates/github/validation_failure.md @@ -0,0 +1,19 @@ +Hi{#for username in usernames} @{username}{/for} — thank you for your contribution! + +The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons: + +{#for reason in reasons} +- {reason} +{/for} + +To resolve this, please: + +1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca +1. Ensure your GitHub username is linked to your Eclipse account +1. Complete and submit the ECA form + +Once done, push a new commit (or rebase) to re-trigger the ECA validation. + +If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). + +Thanks again for your contribution! \ No newline at end of file diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 9f7ee052..10dc9456 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -16,6 +16,7 @@ import java.net.URI; import java.time.ZonedDateTime; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.eclipse.microprofile.rest.client.inject.RestClient; @@ -29,6 +30,7 @@ import org.eclipsefoundation.git.eca.model.ValidationRequest; import org.eclipsefoundation.git.eca.model.ValidationResponse; import org.eclipsefoundation.git.eca.namespace.APIStatusCode; import org.eclipsefoundation.git.eca.namespace.ProviderType; +import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses; import org.eclipsefoundation.git.eca.service.ValidationService; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -52,22 +54,23 @@ class GithubHelperTest { @Test void testHandleGithubWebhookValidation_WithErrors() { - String commitHash = "abc123"; String orgName = "test-org"; String repoName = "test-repo"; int prNumber = 42; + Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); + Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com"); + GithubWebhookRequest request = - createTestWebhookRequest("12345", repoName, orgName, prNumber, commitHash); - Commit testCommit = createTestCommit(commitHash, "Test User", "test@example.com"); + createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + List<Commit> commits = List.of(testCommit1, testCommit2); ValidationRequest vr = ValidationRequest.builder() .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) - .setProvider(ProviderType.GITHUB).setCommits(Collections.singletonList(testCommit)) - .build(); + .setProvider(ProviderType.GITHUB).setCommits(commits).build(); - ValidationResponse mockResponse = - createErrorValidationResponse(commitHash, "Missing ECA for user"); + ValidationResponse mockResponse = createErrorValidationResponse( + Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error"))); when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) .thenReturn(mockResponse); @@ -75,15 +78,84 @@ class GithubHelperTest { helper.handleGithubWebhookValidation(request, vr, null); var expectedRequestBody = GithubPullRequestCommentRequest.builder() - .setBody("The following errors were found in the validation of this pull request:\n" - + "- Missing ECA for user\n" - + "Please check the ECA validation status for more information.") + .setBody( + """ + Hi @testUser @testUser2 — thank you for your contribution! + + The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons: + + - Missing ECA for user + - Another error + + To resolve this, please: + + 1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca + 1. Ensure your GitHub username is linked to your Eclipse account + 1. Complete and submit the ECA form + + Once done, push a new commit (or rebase) to re-trigger the ECA validation. + + If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). + + Thanks again for your contribution!""") .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build(); verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody)); } + @Test + void testHandleGithubWebhookValidation_Success() { + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com"); + + GithubWebhookRequest request = + createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + List<Commit> commits = List.of(testCommit); + + ValidationRequest vr = ValidationRequest.builder() + .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) + .setProvider(ProviderType.GITHUB) + .setCommits(commits) + .build(); + + // Create a successful validation response with no errors + ValidationResponse mockResponse = ValidationResponse.builder() + .setTime(ZonedDateTime.now()) + .setCommits(Map.of(testCommit.getHash(), CommitStatus.builder().build())) + .setTrackedProject(true) + .setStrictMode(true) + .build(); + + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) + .thenReturn(mockResponse); + + helper.handleGithubWebhookValidation(request, vr, null); + + // Verify status was updated to success + verify(ghApi).updateStatus( + Mockito.anyString(), + Mockito.anyString(), + Mockito.eq(orgName), + Mockito.eq(repoName), + Mockito.eq(request.getPullRequest().getHead().getSha()), + Mockito.argThat(status -> status.getState().equals(GithubCommitStatuses.SUCCESS.toString())) + ); + + // Verify no comment was posted + verify(ghApi, Mockito.never()).addComment( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt(), + Mockito.any() + ); + } + /** * Creates a GitHub webhook request for testing purposes. * @@ -135,12 +207,13 @@ class GithubHelperTest { * @param errorMessage The error message * @return ValidationResponse instance */ - private ValidationResponse createErrorValidationResponse(String commitHash, - String errorMessage) { - Map<String, CommitStatus> commits = new HashMap<>(); - CommitStatus status = CommitStatus.builder().build(); - status.addError(errorMessage, APIStatusCode.ERROR_AUTHOR); - commits.put(commitHash, status); + private ValidationResponse createErrorValidationResponse(Map<Commit, List<String>> commitErrors) { + Map<String, CommitStatus> commits = + commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> { + CommitStatus status = CommitStatus.builder().build(); + entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR)); + map.put(entry.getKey().getHash(), status); + }, HashMap::putAll); return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits) .setTrackedProject(true).setStrictMode(true).build(); -- GitLab From dfc43c1bcea997e16b96646e17a151c84ea67c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Wed, 23 Apr 2025 17:14:12 +0200 Subject: [PATCH 07/14] chore: fix formatting --- .../git/eca/api/GithubAPI.java | 45 +- .../GithubPullRequestCommentRequest.java | 5 +- .../git/eca/helper/GithubHelper.java | 440 ++++++++---------- .../git/eca/model/ValidationResponse.java | 21 +- .../git/eca/helper/GithubHelperTest.java | 149 +++--- .../git/eca/test/api/MockGithubAPI.java | 70 +-- 6 files changed, 322 insertions(+), 408 deletions(-) diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index 74e36207..8806ca4a 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -1,8 +1,9 @@ /** * Copyright (c) 2022 Eclipse Foundation * - * This program and the accompanying materials are made available under the terms of the Eclipse - * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> * @@ -54,8 +55,7 @@ public interface GithubAPI { @GET @Path("repos/{org}/{repo}/pulls/{pullNumber}") public PullRequest getPullRequest(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, - @PathParam("org") String organization, @PathParam("repo") String repo, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber); /** @@ -69,15 +69,12 @@ public interface GithubAPI { */ @GET @Path("repos/{org}/{repo}/pulls/{pullNumber}/commits") - public RestResponse<List<GithubCommit>> getCommits( - @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, - @PathParam("org") String organization, @PathParam("repo") String repo, + public RestResponse<List<GithubCommit>> getCommits(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber); /** - * Posts an update to the Github API, using an access token to update a given pull requests - * commit status, targeted using the head sha. + * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha. * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request @@ -90,8 +87,7 @@ public interface GithubAPI { @POST @Path("repos/{org}/{repo}/statuses/{prHeadSha}") public Response updateStatus(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, - @PathParam("org") String organization, @PathParam("repo") String repo, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate); /** @@ -108,14 +104,12 @@ public interface GithubAPI { @POST @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, - @PathParam("org") String organization, @PathParam("repo") String repo, - @PathParam("pullNumber") int pullNumber, - GithubPullRequestCommentRequest commentRequest); + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("pullNumber") int pullNumber, GithubPullRequestCommentRequest commentRequest); /** - * Requires a JWT bearer token for the application to retrieve installations for. Returns a list - * of installations for the given application. + * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given + * application. * * @param params the general params for requests, including pagination * @param bearer JWT bearer token for the target application @@ -123,13 +117,11 @@ public interface GithubAPI { */ @GET @Path("app/installations") - public RestResponse<List<GithubApplicationInstallationData>> getInstallations( - @BeanParam BaseAPIParameters params, + public RestResponse<List<GithubApplicationInstallationData>> getInstallations(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); /** - * Retrieves an access token for a specific installation, given the applications JWT bearer and - * the api version. + * Retrieves an access token for a specific installation, given the applications JWT bearer and the api version. * * @param bearer the authorization header value, should contain the apps signed JWT token * @param apiVersion the API version to target with the request @@ -138,10 +130,8 @@ public interface GithubAPI { */ @POST @Path("app/installations/{installationId}/access_tokens") - public GithubAccessToken getNewAccessToken( - @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, - @HeaderParam("X-GitHub-Api-Version") String apiVersion, - @PathParam("installationId") String installationId); + public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("installationId") String installationId); /** * Returns a list of repositories for the given installation. @@ -152,7 +142,6 @@ public interface GithubAPI { */ @GET @Path("installation/repositories") - public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, - @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); + public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); } diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java index 50b4a9d3..2690fe80 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java @@ -1,8 +1,9 @@ /** * Copyright (c) 2025 Eclipse Foundation * - * This program and the accompanying materials are made available under the terms of the Eclipse - * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index d8f7c901..9d302726 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -1,8 +1,9 @@ /** * Copyright (c) 2023 Eclipse Foundation * - * This program and the accompanying materials are made available under the terms of the Eclipse - * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> * @@ -17,7 +18,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; -import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -40,7 +40,6 @@ import org.eclipsefoundation.git.eca.dto.CommitValidationStatus; import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation; import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking; import org.eclipsefoundation.git.eca.model.Commit; -import org.eclipsefoundation.git.eca.model.CommitStatus; import org.eclipsefoundation.git.eca.model.GitUser; import org.eclipsefoundation.git.eca.model.ValidationRequest; import org.eclipsefoundation.git.eca.model.ValidationResponse; @@ -71,15 +70,14 @@ import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; /** - * This class is used to adapt Github requests to the standard validation workflow in a way that - * could be reused by both resource calls and scheduled tasks for revalidation. + * This class is used to adapt Github requests to the standard validation workflow in a way that could be reused by both resource calls and + * scheduled tasks for revalidation. */ @ApplicationScoped public class GithubHelper { private static final Logger LOGGER = LoggerFactory.getLogger(GithubHelper.class); - private static final String VALIDATION_LOGGING_MESSAGE = - "Setting validation state for {}/#{} to {}"; + private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}"; @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") String apiVersion; @@ -112,32 +110,27 @@ public class GithubHelper { Template failureMessage; /** - * Using the wrapper and the passed unique information about a Github validation instance, - * lookup or create the tracking request and validate the data. + * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and + * validate the data. * * @param wrapper the wrapper for the current request * @param org the name of the GH organization * @param repoName the slug of the repo that has the PR to be validated * @param prNo the PR number for the current request. - * @param forceRevalidation true if revalidation should be forced when there is no changes, - * false otherwise. - * @return the validated response if it is a valid request, or throws a web exception if there - * is a problem validating the request. + * @param forceRevalidation true if revalidation should be forced when there is no changes, false otherwise. + * @return the validated response if it is a valid request, or throws a web exception if there is a problem validating the request. */ - public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, - String repoName, Integer prNo, boolean forceRevalidation) { + public ValidationResponse validateIncomingRequest(RequestWrapper wrapper, String org, String repoName, Integer prNo, + boolean forceRevalidation) { // use logging helper to sanitize newlines as they aren't needed here String fullRepoName = getFullRepoName(org, repoName); // get the installation ID for the given repo if it exists, and if the PR noted exists String installationId = ghAppService.getInstallationForRepo(org, repoName); - Optional<PullRequest> prResponse = - ghAppService.getPullRequest(installationId, org, repoName, prNo); + Optional<PullRequest> prResponse = ghAppService.getPullRequest(installationId, org, repoName, prNo); if (StringUtils.isBlank(installationId)) { - throw new BadRequestException( - "Could not find an ECA app installation for repo name: " + fullRepoName); + throw new BadRequestException("Could not find an ECA app installation for repo name: " + fullRepoName); } else if (prResponse.isEmpty()) { - throw new NotFoundException( - String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName)); + throw new NotFoundException(String.format("Could not find PR '%d' in repo name '%s'", prNo, fullRepoName)); } // prepare the request for consumption @@ -148,49 +141,39 @@ public class GithubHelper { List<String> commitShas = vr.getCommits().stream().map(Commit::getHash).toList(); // there should always be commits for a PR, but in case, lets check if (commitShas.isEmpty()) { - throw new BadRequestException( - String.format("Could not find any commits for %s#%d", fullRepoName, prNo)); + throw new BadRequestException(String.format("Could not find any commits for %s#%d", fullRepoName, prNo)); } LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); - // retrieve the webhook tracking info, or generate an entry to track this PR if it's - // missing. - GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, - installationId, org, repoName, prResponse.get()); + // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing. + GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName, + prResponse.get()); if (updatedTracking == null) { - throw new ServerErrorException( - "Error while attempting to revalidate request, try again later.", + throw new ServerErrorException("Error while attempting to revalidate request, try again later.", Response.Status.INTERNAL_SERVER_ERROR); } // get the commit status of commits to use for checking historic validation - List<CommitValidationStatus> statuses = - validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas); + List<CommitValidationStatus> statuses = validationStatus.getHistoricValidationStatusByShas(wrapper, commitShas); if (!"open".equalsIgnoreCase(prResponse.get().getState()) && statuses.isEmpty()) { - throw new BadRequestException( - "Cannot find validation history for current non-open PR, cannot provide validation status"); + throw new BadRequestException("Cannot find validation history for current non-open PR, cannot provide validation status"); } - // we only want to update/revalidate for open PRs, so don't do this check if the PR is - // merged/closed + // we only want to update/revalidate for open PRs, so don't do this check if the PR is merged/closed if (forceRevalidation) { LOGGER.debug("Forced revalidation for {}#{} has been started", fullRepoName, prNo); - return handleGithubWebhookValidation( - GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); - } else if ("open".equalsIgnoreCase(prResponse.get().getState()) - && commitShas.size() != statuses.size()) { - LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", - fullRepoName, prNo); + return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); + } else if ("open".equalsIgnoreCase(prResponse.get().getState()) && commitShas.size() != statuses.size()) { + LOGGER.debug("Validation for {}#{} does not seem to be current, revalidating commits", fullRepoName, prNo); // using the updated tracking, perform the validation - return handleGithubWebhookValidation( - GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); + return handleGithubWebhookValidation(GithubWebhookRequest.buildFromTracking(updatedTracking), vr, wrapper); } return null; } /** - * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from - * the noted pull request using the installation ID for access/authorization. + * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the + * installation ID for access/authorization. * * @param installationId the ECA app installation ID for the organization * @param repositoryFullName the full name of the repository where the PR resides @@ -199,120 +182,125 @@ public class GithubHelper { * @return the populated validation request for the Github request information */ @SuppressWarnings("null") - public ValidationRequest generateRequest(String installationId, String orgName, - String repositoryName, int pullRequestNumber, String repositoryUrl) { + public ValidationRequest generateRequest(String installationId, String orgName, String repositoryName, int pullRequestNumber, + String repositoryUrl) { checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); - // get the commits that will be validated, don't cache as changes can come in too fast for - // it to - // be useful + // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, - TransformationHelper.formatLog(orgName), - TransformationHelper.formatLog(repositoryName)); + LOGGER + .trace("Retrieving commits for PR {} in repo {}/{}", pullRequestNumber, TransformationHelper.formatLog(orgName), + TransformationHelper.formatLog(repositoryName)); } - List<GithubCommit> commits = - middleware.getAll(i -> ghApi.getCommits(jwtHelper.getGhBearerString(installationId), - apiVersion, orgName, repositoryName, pullRequestNumber)); + List<GithubCommit> commits = middleware + .getAll(i -> ghApi + .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, orgName, repositoryName, pullRequestNumber)); if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Found {} commits for PR {} in repo {}/{}", commits.size(), - pullRequestNumber, TransformationHelper.formatLog(orgName), - TransformationHelper.formatLog(repositoryName)); + LOGGER + .trace("Found {} commits for PR {} in repo {}/{}", commits.size(), pullRequestNumber, + TransformationHelper.formatLog(orgName), TransformationHelper.formatLog(repositoryName)); } // set up the validation request from current data - return ValidationRequest.builder().setProvider(ProviderType.GITHUB) - .setRepoUrl(URI.create(repositoryUrl)).setStrictMode(true) - .setCommits(commits.stream() - .map(c -> Commit.builder().setHash(c.getSha()).setAuthor(GitUser.builder() - .setMail(getNullableString( - () -> c.getCommit().getAuthor().getEmail())) - .setName(getNullableString( - () -> c.getCommit().getAuthor().getName())) - .setExternalId(getNullableString(() -> c.getAuthor().getLogin())) - .build()) - .setCommitter(GitUser.builder() - .setMail(getNullableString( - () -> c.getCommit().getCommitter().getEmail())) - .setName(getNullableString( - () -> c.getCommit().getCommitter().getName())) - .setExternalId(getNullableString( - () -> c.getCommitter().getLogin())) + return ValidationRequest + .builder() + .setProvider(ProviderType.GITHUB) + .setRepoUrl(URI.create(repositoryUrl)) + .setStrictMode(true) + .setCommits(commits + .stream() + .map(c -> Commit + .builder() + .setHash(c.getSha()) + .setAuthor(GitUser + .builder() + .setMail(getNullableString(() -> c.getCommit().getAuthor().getEmail())) + .setName(getNullableString(() -> c.getCommit().getAuthor().getName())) + .setExternalId(getNullableString(() -> c.getAuthor().getLogin())) + .build()) + .setCommitter(GitUser + .builder() + .setMail(getNullableString(() -> c.getCommit().getCommitter().getEmail())) + .setName(getNullableString(() -> c.getCommit().getCommitter().getName())) + .setExternalId(getNullableString(() -> c.getCommitter().getLogin())) .build()) - .setParents( - c.getParents().stream().map(ParentCommit::getSha).toList()) + .setParents(c.getParents().stream().map(ParentCommit::getSha).toList()) .build()) .toList()) .build(); } /** - * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As - * commits need to pass branch rules including the existing ECA check, we don't need to check - * the commits here. + * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including + * the existing ECA check, we don't need to check the commits here. * - * @param request information about the request from the GH webhook on what resource requested - * revalidation. Used to target the commit status of the resources + * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit + * status of the resources */ public void sendMergeQueueStatus(GithubWebhookRequest request) { if (request.getMergeGroup() == null) { - throw new BadRequestException( - "Merge group object required in webhook request to send status for merge queue"); + throw new BadRequestException("Merge group object required in webhook request to send status for merge queue"); } // send the success status for the head SHA - ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), - apiVersion, request.getRepository().getOwner().getLogin(), - request.getRepository().getName(), request.getMergeGroup().getHeadSha(), - GithubCommitStatusRequest.builder().setDescription( - "Commits in merge group should be previously validated, auto-passing HEAD commit") - .setState(GithubCommitStatuses.SUCCESS.toString()) - .setContext(webhooksConfig.github().context()).build()); + ghApi + .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, + request.getRepository().getOwner().getLogin(), request.getRepository().getName(), + request.getMergeGroup().getHeadSha(), + GithubCommitStatusRequest + .builder() + .setDescription("Commits in merge group should be previously validated, auto-passing HEAD commit") + .setState(GithubCommitStatuses.SUCCESS.toString()) + .setContext(webhooksConfig.github().context()) + .build()); } /** - * Process the current request and update the checks state to pending then success or failure. - * Contains verbose TRACE logging for more info on the states of the validation for more - * information + * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more + * info on the states of the validation for more information * - * @param request information about the request from the GH webhook on what resource requested - * revalidation. Used to target the commit status of the resources - * @param vr the pseudo request generated from the contextual webhook data. Used to make use of - * existing validation logic. + * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit + * status of the resources + * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic. * @return true if the validation passed, false otherwise. */ - public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, - ValidationRequest vr, RequestWrapper wrapper) { + public ValidationResponse handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr, RequestWrapper wrapper) { // null check the pull request to make sure that someone didn't push a bad value PullRequest pr = request.getPullRequest(); if (pr == null) { - throw new IllegalStateException( - "Pull request should not be null when handling validation"); + throw new IllegalStateException("Pull request should not be null when handling validation"); } // update the status before processing - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), - pr.getNumber(), GithubCommitStatuses.PENDING); + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.PENDING); updateCommitStatus(request, GithubCommitStatuses.PENDING); // validate the response - LOGGER.trace("Begining validation of request for {}/#{}", - request.getRepository().getFullName(), pr.getNumber()); + LOGGER.trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(), pr.getNumber()); ValidationResponse r = validation.validateIncomingRequest(vr, wrapper); if (r.getPassed()) { - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), - pr.getNumber(), GithubCommitStatuses.SUCCESS); + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.SUCCESS); updateCommitStatus(request, GithubCommitStatuses.SUCCESS); } else { - LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), - pr.getNumber(), GithubCommitStatuses.FAILURE); + LOGGER.trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), pr.getNumber(), GithubCommitStatuses.FAILURE); updateCommitStatus(request, GithubCommitStatuses.FAILURE); commentOnFailure(request, - r.getCommits().entrySet().stream() - .filter(e -> !e.getValue().getErrors().isEmpty()).map(e -> e.getKey()) - .map(hash -> findCommitByHash(vr, hash)).filter(Optional::isPresent) - .map(Optional::get).map(c -> c.getAuthor().getName()) + r + .getCommits() + .entrySet() + .stream() + .filter(e -> !e.getValue().getErrors().isEmpty()) + .map(e -> e.getKey()) + .map(hash -> findCommitByHash(vr, hash)) + .filter(Optional::isPresent) + .map(Optional::get) + .map(c -> c.getAuthor().getName()) .collect(Collectors.toSet()), - r.getCommits().values().stream().flatMap(c -> c.getErrors().stream()) - .map(e -> e.getMessage()).collect(Collectors.toSet())); + r + .getCommits() + .values() + .stream() + .flatMap(c -> c.getErrors().stream()) + .map(e -> e.getMessage()) + .collect(Collectors.toSet())); } return r; } @@ -325,41 +313,35 @@ public class GithubHelper { * @return An Optional containing the matching Commit if found, or empty if not found */ private Optional<Commit> findCommitByHash(ValidationRequest vr, String hash) { - return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash())) - .findFirst(); + return vr.getCommits().stream().filter((commit) -> hash.equals(commit.getHash())).findFirst(); } - /** - * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, - * and will update the state of existing requests as well. + * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing + * requests as well. * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pr the pull request targeted by the validation request. - * @return a new or updated tracking object, or null if there was an error in saving the - * information + * @return a new or updated tracking object, or null if there was an error in saving the information */ - public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, - String installationId, String orgName, String repositoryName, PullRequest pr) { - return updateGithubTrackingIfMissing( - wrapper, getExistingRequestInformation(wrapper, installationId, orgName, - repositoryName, pr.getNumber()), - pr, installationId, orgName, repositoryName); + public GithubWebhookTracking retrieveAndUpdateTrackingInformation(RequestWrapper wrapper, String installationId, String orgName, + String repositoryName, PullRequest pr) { + return updateGithubTrackingIfMissing(wrapper, + getExistingRequestInformation(wrapper, installationId, orgName, repositoryName, pr.getNumber()), pr, installationId, + orgName, repositoryName); } /** - * Checks if the Github tracking is present for the current request, and if missing will - * generate a new record and save it. + * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it. * * @param tracking the optional tracking entry for the current request * @param request the pull request that is being validated * @param installationId the ECA app installation ID for the current request * @param fullRepoName the full repo name for the validation request */ - public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, - Optional<GithubWebhookTracking> tracking, PullRequest request, String installationId, - String orgName, String repositoryName) { + public GithubWebhookTracking updateGithubTrackingIfMissing(RequestWrapper wrapper, Optional<GithubWebhookTracking> tracking, + PullRequest request, String installationId, String orgName, String repositoryName) { String fullRepoName = getFullRepoName(orgName, repositoryName); // if there is no tracking present, create the missing tracking and persist it GithubWebhookTracking updatedTracking; @@ -372,9 +354,9 @@ public class GithubHelper { updatedTracking.setNeedsRevalidation(false); updatedTracking.setManualRevalidationCount(0); if (!"open".equalsIgnoreCase(request.getState())) { - LOGGER.warn( - "The PR {} in {} is not in an open state, and will not be validated to follow our validation practice", - updatedTracking.getPullRequestNumber(), fullRepoName); + LOGGER + .warn("The PR {} in {} is not in an open state, and will not be validated to follow our validation practice", + updatedTracking.getPullRequestNumber(), fullRepoName); } } else { // uses the DB version as a base if available @@ -385,96 +367,77 @@ public class GithubHelper { updatedTracking.setHeadSha(request.getHead().getSha()); // save the data, and log on its success or failure - List<GithubWebhookTracking> savedTracking = - dao.add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), - Arrays.asList(updatedTracking)); + List<GithubWebhookTracking> savedTracking = dao + .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(updatedTracking)); if (savedTracking.isEmpty()) { - LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", - fullRepoName, request.getNumber()); + LOGGER.warn("Unable to create new GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber()); return null; } // return the updated tracking when successful - LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", - fullRepoName, request.getNumber()); + LOGGER.debug("Created/updated GH tracking record for request to validate {}#{}", fullRepoName, request.getNumber()); return savedTracking.get(0); } /** - * Attempts to retrieve a webhook tracking record given the installation, repository, and pull - * request number. + * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number. * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pullRequestNumber the pull request number that is being processed currently * @return the webhook tracking record if it can be found, or an empty optional. */ - public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, - String installationId, String orgName, String repositoryName, int pullRequestNumber) { + public Optional<GithubWebhookTracking> getExistingRequestInformation(RequestWrapper wrapper, String installationId, String orgName, + String repositoryName, int pullRequestNumber) { checkRequestParameters(installationId, orgName, repositoryName, pullRequestNumber); String repositoryFullName = getFullRepoName(orgName, repositoryName); MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, installationId); params.add(GitEcaParameterNames.REPOSITORY_FULL_NAME_RAW, repositoryFullName); - params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, - Integer.toString(pullRequestNumber)); - return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)) - .stream().findFirst(); + params.add(GitEcaParameterNames.PULL_REQUEST_NUMBER_RAW, Integer.toString(pullRequestNumber)); + return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst(); } /** - * Using the configured GitHub application JWT and application ID, fetches all active - * installations and updates the DB cache containing the installation data. After all records - * are updated, the starting timestamp is used to delete stale records that were updated before + * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing + * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before * the starting time. * - * This does not use locking, but with the way that updates are done to look for long stale - * entries, multiple concurrent runs would not lead to a loss of data/integrity. + * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not + * lead to a loss of data/integrity. */ public void updateAppInstallationRecords() { // get the installations for the currently configured app - List<GithubApplicationInstallationData> installations = - middleware.getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt())); - // check that there are installations, none may indicate an issue, and better to fail - // pessimistically + List<GithubApplicationInstallationData> installations = middleware + .getAll(i -> ghApi.getInstallations(i, "Bearer " + jwt.generateJwt())); + // check that there are installations, none may indicate an issue, and better to fail pessimistically if (installations.isEmpty()) { - LOGGER.warn( - "Did not find any installations for the currently configured Github application"); + LOGGER.warn("Did not find any installations for the currently configured Github application"); return; } // trace log the installations for more context LOGGER.debug("Found {} installations to cache", installations.size()); // create a common timestamp that looks for entries stale for more than a day - Date startingTimestamp = - new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli()); + Date startingTimestamp = new Date(ZonedDateTime.now().minus(1, ChronoUnit.DAYS).toInstant().toEpochMilli()); // from installations, build records and start the processing for each entry - List<GithubApplicationInstallation> installationRecords = - installations.stream().map(this::processInstallation).toList(); - - // once records are prepared, persist them back to the database with updates where necessary - // as - // a batch - RequestWrapper wrap = new FlatRequestWrapper( - URI.create("https://api.eclipse.org/git/webhooks/github/installations")); - List<GithubApplicationInstallation> repoRecords = - dao.add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), - installationRecords); + List<GithubApplicationInstallation> installationRecords = installations.stream().map(this::processInstallation).toList(); + + // once records are prepared, persist them back to the database with updates where necessary as a batch + RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations")); + List<GithubApplicationInstallation> repoRecords = dao + .add(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class)), installationRecords); if (repoRecords.size() != installationRecords.size()) { - LOGGER.warn( - "Background update to installation records had a size mismatch, cleaning will be skipped for this run"); + LOGGER.warn("Background update to installation records had a size mismatch, cleaning will be skipped for this run"); return; } // build query to do cleanup of stale records MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.APPLICATION_ID_RAW, - Integer.toString(webhooksConfig.github().appId())); - params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, - DateTimeHelper.toRFC3339(startingTimestamp)); + params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId())); + params.add(GitEcaParameterNames.LAST_UPDATED_BEFORE_RAW, DateTimeHelper.toRFC3339(startingTimestamp)); // run the delete call, removing stale entries - dao.delete( - new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); + dao.delete(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); } /** @@ -498,26 +461,28 @@ public class GithubHelper { PullRequest pr = request.getPullRequest(); if (pr == null) { // should not be reachable, but for safety - throw new IllegalStateException( - "Pull request should not be null when handling validation"); + throw new IllegalStateException("Pull request should not be null when handling validation"); } - LOGGER.trace("Generated access token for installation {}: {}", - request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); - ghApi.updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), - apiVersion, request.getRepository().getOwner().getLogin(), - request.getRepository().getName(), pr.getHead().getSha(), - GithubCommitStatusRequest.builder().setDescription(state.getMessage()) - .setState(state.toString()) - .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" - + request.getRepository().getFullName() + '/' + pr.getNumber()) - .setContext(webhooksConfig.github().context()).build()); + LOGGER + .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + ghApi + .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, + request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(), + GithubCommitStatusRequest + .builder() + .setDescription(state.getMessage()) + .setState(state.toString()) + .setTargetUrl(webhooksConfig.github().serverTarget() + "/git/eca/status/gh/" + + request.getRepository().getFullName() + '/' + pr.getNumber()) + .setContext(webhooksConfig.github().context()) + .build()); } /** - * This method posts a comment to the pull request detailing the validation errors and - * mentioning the usernames that need to take action. + * This method posts a comment to the pull request detailing the validation errors and mentioning the usernames that need to take + * action. * * @param request The request containing repository and pull request information * @param usernames Set of GitHub usernames that need to take action @@ -525,8 +490,7 @@ public class GithubHelper { * * @throws IllegalArgumentException if the request parameter is null */ - private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, - Set<String> errors) { + private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, Set<String> errors) { // This should never happen given the logic behind the getPassed, but adding this just in // case that logic changes if (errors.isEmpty()) { @@ -538,28 +502,26 @@ public class GithubHelper { String repositoryName = request.getRepository().getName(); Integer pullRequestNumber = request.getPullRequest().getNumber(); - GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest.builder() - .setBody(failureMessage.data("reasons", errors).data("usernames", usernames) - .render()) - .setCommitId(Objects.requireNonNull(request, "Request cannot be null") - .getPullRequest().getHead().getSha()) - .setPath(".").build(); + GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest + .builder() + .setBody(failureMessage.data("reasons", errors).data("usernames", usernames).render()) + .setCommitId(Objects.requireNonNull(request, "Request cannot be null").getPullRequest().getHead().getSha()) + .setPath(".") + .build(); - LOGGER.trace("Generated access token for installation {}: {}", - request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + LOGGER + .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); - LOGGER.trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber, - TransformationHelper.formatLog(login), - TransformationHelper.formatLog(repositoryName), comment.getBody()); + LOGGER + .trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login), + TransformationHelper.formatLog(repositoryName), comment.getBody()); - ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, - comment); + ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment); } /** - * Wraps a nullable value fetch to handle errors and will return null if the value can't be - * retrieved. + * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved. * * @param supplier the method with potentially nullable values * @return the value if it can be found, or null @@ -581,8 +543,7 @@ public class GithubHelper { * @param pullRequestNumber the pull request number that is being processed currently * @throws BadRequestException if at least one of the parameters is in an invalid state. */ - private void checkRequestParameters(String installationId, String org, String repoName, - int pullRequestNumber) { + private void checkRequestParameters(String installationId, String org, String repoName, int pullRequestNumber) { List<String> missingFields = new ArrayList<>(); if (StringUtils.isBlank(installationId)) { missingFields.add(GitEcaParameterNames.INSTALLATION_ID_RAW); @@ -599,33 +560,27 @@ public class GithubHelper { // throw exception if some fields are missing as we can't continue to process the request if (!missingFields.isEmpty()) { - throw new BadRequestException("Missing fields in order to prepare request: " - + StringUtils.join(missingFields, ' ')); + throw new BadRequestException("Missing fields in order to prepare request: " + StringUtils.join(missingFields, ' ')); } } /** - * Converts the raw installation data from Github into a short record to be persisted to - * database as a form of persistent caching. Checks database for existing record, and returns - * record with a touched date for existing entries. + * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching. + * Checks database for existing record, and returns record with a touched date for existing entries. * * @param ghInstallation raw Github installation record for current application * @return the new or updated installation record to be persisted to the database. */ - private GithubApplicationInstallation processInstallation( - GithubApplicationInstallationData ghInstallation) { - RequestWrapper wrap = new FlatRequestWrapper( - URI.create("https://api.eclipse.org/git/webhooks/github/installations")); + private GithubApplicationInstallation processInstallation(GithubApplicationInstallationData ghInstallation) { + RequestWrapper wrap = new FlatRequestWrapper(URI.create("https://api.eclipse.org/git/webhooks/github/installations")); // build the lookup query for the current installation record MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); - params.add(GitEcaParameterNames.APPLICATION_ID_RAW, - Integer.toString(webhooksConfig.github().appId())); - params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, - Integer.toString(ghInstallation.getId())); + params.add(GitEcaParameterNames.APPLICATION_ID_RAW, Integer.toString(webhooksConfig.github().appId())); + params.add(GitEcaParameterNames.INSTALLATION_ID_RAW, Integer.toString(ghInstallation.getId())); // lookup existing records in the database - List<GithubApplicationInstallation> existingRecords = dao.get( - new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); + List<GithubApplicationInstallation> existingRecords = dao + .get(new RDBMSQuery<>(wrap, filters.get(GithubApplicationInstallation.class), params)); // check for existing entry, creating if missing GithubApplicationInstallation installation; @@ -637,19 +592,16 @@ public class GithubHelper { installation = existingRecords.get(0); } // update the basic stats to handle renames, and update last updated time - // login is technically nullable, so this might be missing. This is best we can do, as we - // can't - // look up by id - installation.setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) - ? ghInstallation.getAccount().getLogin() - : "UNKNOWN"); + // login is technically nullable, so this might be missing. This is best we can do, as we can't look up by id + installation + .setName(StringUtils.isNotBlank(ghInstallation.getAccount().getLogin()) ? ghInstallation.getAccount().getLogin() + : "UNKNOWN"); installation.setLastUpdated(new Date()); return installation; } /** - * Retrieves the full repo name for a given org and repo name, used for storage and legacy - * support. + * Retrieves the full repo name for a given org and repo name, used for storage and legacy support. * * @param org organization name being targeted * @param repo name of repo being targeted diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java index 2a5ea7a3..9d27dec4 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java +++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java @@ -1,8 +1,9 @@ /********************************************************************* * Copyright (c) 2020 Eclipse Foundation. * - * This program and the accompanying materials are made available under the terms of the Eclipse - * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> * @@ -59,11 +60,9 @@ public abstract class ValidationResponse { /** @param error message to add to the API response */ public void addError(String hash, String error, APIStatusCode code) { if (this.getTrackedProject() || this.getStrictMode()) { - getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()) - .addError(error, code); + getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addError(error, code); } else { - getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()) - .addWarning(error, code); + getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addWarning(error, code); } } @@ -74,8 +73,7 @@ public abstract class ValidationResponse { /** * Converts the APIResponse to a web response with appropriate status. * - * @return a web response with status {@link Status.OK} if the commits pass validation, - * {@link Status.FORBIDDEN} otherwise. + * @return a web response with status {@link Status.OK} if the commits pass validation, {@link Status.FORBIDDEN} otherwise. */ public Response toResponse() { // update error count before returning @@ -87,8 +85,11 @@ public abstract class ValidationResponse { } public static Builder builder() { - return new AutoValue_ValidationResponse.Builder().setStrictMode(false) - .setTrackedProject(false).setTime(ZonedDateTime.now()).setCommits(new HashMap<>()); + return new AutoValue_ValidationResponse.Builder() + .setStrictMode(false) + .setTrackedProject(false) + .setTime(ZonedDateTime.now()) + .setCommits(new HashMap<>()); } @AutoValue.Builder diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 10dc9456..14a65937 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -1,8 +1,9 @@ -/* +/** * Copyright (c) 2025 Eclipse Foundation * - * This program and the accompanying materials are made available under the terms of the Eclipse - * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ @@ -30,7 +31,6 @@ import org.eclipsefoundation.git.eca.model.ValidationRequest; import org.eclipsefoundation.git.eca.model.ValidationResponse; import org.eclipsefoundation.git.eca.namespace.APIStatusCode; import org.eclipsefoundation.git.eca.namespace.ProviderType; -import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses; import org.eclipsefoundation.git.eca.service.ValidationService; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -61,23 +61,25 @@ class GithubHelperTest { Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com"); - GithubWebhookRequest request = - createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); List<Commit> commits = List.of(testCommit1, testCommit2); - ValidationRequest vr = ValidationRequest.builder() + ValidationRequest vr = ValidationRequest + .builder() .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) - .setProvider(ProviderType.GITHUB).setCommits(commits).build(); + .setProvider(ProviderType.GITHUB) + .setCommits(commits) + .build(); ValidationResponse mockResponse = createErrorValidationResponse( Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error"))); - when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) - .thenReturn(mockResponse); + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse); helper.handleGithubWebhookValidation(request, vr, null); - var expectedRequestBody = GithubPullRequestCommentRequest.builder() + var expectedRequestBody = GithubPullRequestCommentRequest + .builder() .setBody( """ Hi @testUser @testUser2 — thank you for your contribution! @@ -98,62 +100,13 @@ class GithubHelperTest { If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). Thanks again for your contribution!""") - .setCommitId(request.getPullRequest().getHead().getSha()).setPath(".").build(); - - verify(ghApi).addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), - Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody)); - } - - @Test - void testHandleGithubWebhookValidation_Success() { - String orgName = "test-org"; - String repoName = "test-repo"; - int prNumber = 42; - - Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com"); - - GithubWebhookRequest request = - createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); - List<Commit> commits = List.of(testCommit); - - ValidationRequest vr = ValidationRequest.builder() - .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) - .setProvider(ProviderType.GITHUB) - .setCommits(commits) + .setCommitId(request.getPullRequest().getHead().getSha()) + .setPath(".") .build(); - // Create a successful validation response with no errors - ValidationResponse mockResponse = ValidationResponse.builder() - .setTime(ZonedDateTime.now()) - .setCommits(Map.of(testCommit.getHash(), CommitStatus.builder().build())) - .setTrackedProject(true) - .setStrictMode(true) - .build(); - - when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())) - .thenReturn(mockResponse); - - helper.handleGithubWebhookValidation(request, vr, null); - - // Verify status was updated to success - verify(ghApi).updateStatus( - Mockito.anyString(), - Mockito.anyString(), - Mockito.eq(orgName), - Mockito.eq(repoName), - Mockito.eq(request.getPullRequest().getHead().getSha()), - Mockito.argThat(status -> status.getState().equals(GithubCommitStatuses.SUCCESS.toString())) - ); - - // Verify no comment was posted - verify(ghApi, Mockito.never()).addComment( - Mockito.anyString(), - Mockito.anyString(), - Mockito.anyString(), - Mockito.anyString(), - Mockito.anyInt(), - Mockito.any() - ); + verify(ghApi) + .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber), + Mockito.eq(expectedRequestBody)); } /** @@ -166,21 +119,23 @@ class GithubHelperTest { * @param commitSha The commit SHA * @return GithubWebhookRequest instance */ - private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, - String orgName, int prNumber, String commitSha) { - return GithubWebhookRequest.builder() - .setInstallation( - GithubWebhookRequest.Installation.builder().setId(installationId).build()) - .setRepository( - GithubWebhookRequest.Repository.builder().setName(repoName) - .setFullName(orgName + "/" + repoName) - .setOwner(GithubWebhookRequest.RepositoryOwner.builder() - .setLogin(orgName).build()) - .setHtmlUrl("https://github.com/" + orgName + "/" + repoName) - .build()) - .setPullRequest(GithubWebhookRequest.PullRequest.builder().setNumber(prNumber) - .setState("open").setHead(GithubWebhookRequest.PullRequestHead.builder() - .setSha(commitSha).build()) + private GithubWebhookRequest createTestWebhookRequest(String installationId, String repoName, String orgName, int prNumber, + String commitSha) { + return GithubWebhookRequest + .builder() + .setInstallation(GithubWebhookRequest.Installation.builder().setId(installationId).build()) + .setRepository(GithubWebhookRequest.Repository + .builder() + .setName(repoName) + .setFullName(orgName + "/" + repoName) + .setOwner(GithubWebhookRequest.RepositoryOwner.builder().setLogin(orgName).build()) + .setHtmlUrl("https://github.com/" + orgName + "/" + repoName) + .build()) + .setPullRequest(GithubWebhookRequest.PullRequest + .builder() + .setNumber(prNumber) + .setState("open") + .setHead(GithubWebhookRequest.PullRequestHead.builder().setSha(commitSha).build()) .build()) .build(); } @@ -195,9 +150,15 @@ class GithubHelperTest { */ private Commit createTestCommit(String commitHash, String userName, String userEmail) { GitUser testUser = GitUser.builder().setName(userName).setMail(userEmail).build(); - return Commit.builder().setHash(commitHash).setAuthor(testUser).setCommitter(testUser) - .setSubject("Test commit").setBody("Test commit body") - .setParents(Collections.emptyList()).build(); + return Commit + .builder() + .setHash(commitHash) + .setAuthor(testUser) + .setCommitter(testUser) + .setSubject("Test commit") + .setBody("Test commit body") + .setParents(Collections.emptyList()) + .build(); } /** @@ -208,15 +169,19 @@ class GithubHelperTest { * @return ValidationResponse instance */ private ValidationResponse createErrorValidationResponse(Map<Commit, List<String>> commitErrors) { - Map<String, CommitStatus> commits = - commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> { - CommitStatus status = CommitStatus.builder().build(); - entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR)); - map.put(entry.getKey().getHash(), status); - }, HashMap::putAll); - - return ValidationResponse.builder().setTime(ZonedDateTime.now()).setCommits(commits) - .setTrackedProject(true).setStrictMode(true).build(); + Map<String, CommitStatus> commits = commitErrors.entrySet().stream().collect(HashMap::new, (map, entry) -> { + CommitStatus status = CommitStatus.builder().build(); + entry.getValue().forEach(error -> status.addError(error, APIStatusCode.ERROR_AUTHOR)); + map.put(entry.getKey().getHash(), status); + }, HashMap::putAll); + + return ValidationResponse + .builder() + .setTime(ZonedDateTime.now()) + .setCommits(commits) + .setTrackedProject(true) + .setStrictMode(true) + .build(); } } diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 5f146677..ddeac400 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -1,8 +1,9 @@ /********************************************************************* * Copyright (c) 2023 Eclipse Foundation. * - * This program and the accompanying materials are made available under the terms of the Eclipse - * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/ + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org> * @@ -47,29 +48,39 @@ public class MockGithubAPI implements GithubAPI { public MockGithubAPI() { this.commitStatuses = new HashMap<>(); this.commits = new HashMap<>(); - this.commits.put("eclipsefdn/sample", - Map.of(42, Arrays.asList(GithubCommit.builder().setSha("sha-1234") - .setAuthor(GithubCommitUser.builder().setLogin("testuser").build()) - .setCommitter(GithubCommitUser.builder().setLogin("testuser").build()) - .setParents(Collections.emptyList()) - .setCommit(CommitData.builder() - .setAuthor(GitCommitUser.builder().setName("The Wizard") - .setEmail("code.wiz@important.co").build()) - .setCommitter(GitCommitUser.builder().setName("The Wizard") - .setEmail("code.wiz@important.co").build()) - .build()) - .build()))); + this.commits + .put("eclipsefdn/sample", + Map + .of(42, Arrays + .asList(GithubCommit + .builder() + .setSha("sha-1234") + .setAuthor(GithubCommitUser.builder().setLogin("testuser").build()) + .setCommitter(GithubCommitUser.builder().setLogin("testuser").build()) + .setParents(Collections.emptyList()) + .setCommit(CommitData + .builder() + .setAuthor(GitCommitUser + .builder() + .setName("The Wizard") + .setEmail("code.wiz@important.co") + .build()) + .setCommitter(GitCommitUser + .builder() + .setName("The Wizard") + .setEmail("code.wiz@important.co") + .build()) + .build()) + .build()))); } @Override - public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, - int pullNumber) { + public PullRequest getPullRequest(String bearer, String apiVersion, String org, String repo, int pullNumber) { return PullRequest.builder().build(); } @Override - public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, - String repo, int pullNumber) { + public RestResponse<List<GithubCommit>> getCommits(String bearer, String apiVersion, String org, String repo, int pullNumber) { String repoFullName = org + '/' + repo; List<GithubCommit> results = commits.get(repoFullName).get(pullNumber); if (results == null || !results.isEmpty()) { @@ -79,37 +90,32 @@ public class MockGithubAPI implements GithubAPI { } @Override - public Response updateStatus(String bearer, String apiVersion, String org, String repo, - String prHeadSha, GithubCommitStatusRequest commitStatusUpdate) { + public Response updateStatus(String bearer, String apiVersion, String org, String repo, String prHeadSha, + GithubCommitStatusRequest commitStatusUpdate) { String repoFullName = org + '/' + repo; - commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, - commitStatusUpdate.getState(), (k, v) -> v); + commitStatuses.computeIfAbsent(repoFullName, m -> new HashMap<>()).merge(prHeadSha, commitStatusUpdate.getState(), (k, v) -> v); return Response.ok().build(); } @Override - public Response addComment(String bearer, String apiVersion, String organization, String repo, - int pullNumber, GithubPullRequestCommentRequest comment) { + public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber, + GithubPullRequestCommentRequest comment) { return Response.ok().build(); } @Override - public RestResponse<List<GithubApplicationInstallationData>> getInstallations( - BaseAPIParameters params, String bearer) { + public RestResponse<List<GithubApplicationInstallationData>> getInstallations(BaseAPIParameters params, String bearer) { throw new UnsupportedOperationException("Unimplemented method 'getInstallations'"); } @Override - public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, - String installationId) { - return GithubAccessToken.builder().setToken("gh-token-" + installationId) - .setExpiresAt(LocalDateTime.now().plusHours(1L)).build(); + public GithubAccessToken getNewAccessToken(String bearer, String apiVersion, String installationId) { + return GithubAccessToken.builder().setToken("gh-token-" + installationId).setExpiresAt(LocalDateTime.now().plusHours(1L)).build(); } @Override public Response getInstallationRepositories(BaseAPIParameters params, String bearer) { - throw new UnsupportedOperationException( - "Unimplemented method 'getInstallationRepositories'"); + throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'"); } } -- GitLab From 51241f82e1246c65eb254887467ba90abf9105cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Thu, 24 Apr 2025 15:58:00 +0200 Subject: [PATCH 08/14] feat: checking for existing comments before commenting one more time --- .../git/eca/api/GithubAPI.java | 57 ++++++++++++++++++- .../api/models/GithubPullRequestComment.java | 53 +++++++++++++++++ .../git/eca/helper/GithubHelper.java | 27 +++++++-- .../git/eca/helper/GithubHelperTest.java | 49 ++++++++++++++++ .../git/eca/test/api/MockGithubAPI.java | 19 +++++-- 5 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index 8806ca4a..418bc631 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -11,7 +11,9 @@ */ package org.eclipsefoundation.git.eca.api; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters; @@ -19,17 +21,20 @@ import org.eclipsefoundation.git.eca.api.models.GithubAccessToken; import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationData; import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; -import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment; import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.jboss.resteasy.reactive.RestResponse; import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PATCH; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; @@ -107,6 +112,24 @@ public interface GithubAPI { @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, @PathParam("pullNumber") int pullNumber, GithubPullRequestCommentRequest commentRequest); + /** + * Lists comments on a pull request. + * + * @param bearer authorization header value, access token for application with access to repo + * @param apiVersion the version of the GH API to target when making the request + * @param organization the organization that owns the targeted repo + * @param repo the repo name that is being targeted + * @param pullNumber the number of the pull request + * @param perPage number of items to return per page, max 100 + * @param page page number to return + * @return list of comments on the pull request + */ + @GET + @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") + public RestResponse<List<GithubPullRequestComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, + @PathParam("pullNumber") int pullNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page); + /** * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given * application. @@ -144,4 +167,36 @@ public interface GithubAPI { @Path("installation/repositories") public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); + /** + * Recursively fetches all comments from a pull request by handling pagination manually. + * + * @param bearer GitHub bearer token + * @param apiVersion GitHub API version + * @param org organization name + * @param repo repository name + * @param prNumber pull request number + * @return List of all comments from the pull request + */ + default List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo, + int prNumber) { + List<GithubPullRequestComment> allComments = new ArrayList<>(); + int page = 1; + int perPage = 100; // GitHub's maximum items per page + + // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've + // reached the end + while (true) { + RestResponse<List<GithubPullRequestComment>> response = getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); + + List<GithubPullRequestComment> comments = response.getEntity(); + if (Objects.isNull(comments) || comments.isEmpty()) { + break; + } + + allComments.addAll(comments); + page++; + } + + return allComments; + } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java new file mode 100644 index 00000000..fe0b5ff1 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipsefoundation.git.eca.api.models; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.google.auto.value.AutoValue; + +@AutoValue +@JsonDeserialize(builder = AutoValue_GithubPullRequestComment.Builder.class) +public abstract class GithubPullRequestComment { + public abstract String getBody(); + public abstract int getId(); + public abstract GithubUser getUser(); + + @AutoValue + @JsonDeserialize(builder = AutoValue_GithubPullRequestComment_GithubUser.Builder.class) + public abstract static class GithubUser { + public abstract int getId(); + + public static Builder builder() { + return new AutoValue_GithubPullRequestComment_GithubUser.Builder(); + } + + @AutoValue.Builder + @JsonPOJOBuilder(withPrefix = "set") + public abstract static class Builder { + public abstract Builder setId(int id); + public abstract GithubUser build(); + } + } + + public static Builder builder() { + return new AutoValue_GithubPullRequestComment.Builder(); + } + + @AutoValue.Builder + @JsonPOJOBuilder(withPrefix = "set") + public abstract static class Builder { + public abstract Builder setBody(String body); + public abstract Builder setId(int id); + public abstract Builder setUser(GithubUser user); + public abstract GithubPullRequestComment build(); + } +} diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 9d302726..073032ed 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -32,6 +32,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment; import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; @@ -145,7 +146,7 @@ public class GithubHelper { } LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); - // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing. + // retrieve the webhook tracking info, or generate an entry to track this PR GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName, prResponse.get()); if (updatedTracking == null) { @@ -491,8 +492,7 @@ public class GithubHelper { * @throws IllegalArgumentException if the request parameter is null */ private void commentOnFailure(GithubWebhookRequest request, Set<String> usernames, Set<String> errors) { - // This should never happen given the logic behind the getPassed, but adding this just in - // case that logic changes + // This should never happen given the logic behind the getPassed, but adding this just in case that logic changes if (errors.isEmpty()) { return; } @@ -502,10 +502,27 @@ public class GithubHelper { String repositoryName = request.getRepository().getName(); Integer pullRequestNumber = request.getPullRequest().getNumber(); + // Get existing comments using pagination + List<GithubPullRequestComment> comments = ghApi + .getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber); + + Set<String> nonMentionedUsers = usernames + .stream() + .filter(username -> comments + .stream() + .noneMatch(comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username)))) + .collect(Collectors.toSet()); + + // If all the users have already been mentioned, skip commenting + if (nonMentionedUsers.isEmpty()) { + LOGGER.debug("All users have already been mentioned in the comments, skipping comment creation."); + return; + } + GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest .builder() .setBody(failureMessage.data("reasons", errors).data("usernames", usernames).render()) - .setCommitId(Objects.requireNonNull(request, "Request cannot be null").getPullRequest().getHead().getSha()) + .setCommitId(request.getPullRequest().getHead().getSha()) .setPath(".") .build(); @@ -514,7 +531,7 @@ public class GithubHelper { jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); LOGGER - .trace("Adding comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login), + .trace("Adding new comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login), TransformationHelper.formatLog(repositoryName), comment.getBody()); ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment); diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 14a65937..79da0acc 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -54,6 +54,7 @@ class GithubHelperTest { @Test void testHandleGithubWebhookValidation_WithErrors() { + // Set up test data for the PR String orgName = "test-org"; String repoName = "test-repo"; int prNumber = 42; @@ -61,9 +62,11 @@ class GithubHelperTest { Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com"); + // Create webhook request simulating a GitHub PR event GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); List<Commit> commits = List.of(testCommit1, testCommit2); + // Build a validation request as the service would receive it ValidationRequest vr = ValidationRequest .builder() .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) @@ -71,13 +74,17 @@ class GithubHelperTest { .setCommits(commits) .build(); + // Create a mock validation response with multiple errors ValidationResponse mockResponse = createErrorValidationResponse( Map.of(testCommit1, List.of("Missing ECA for user"), testCommit2, List.of("Missing ECA for user", "Another error"))); + // Mock validation service to return our error response when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse); + // Execute the validation handler helper.handleGithubWebhookValidation(request, vr, null); + // Prepare the expected error comment that should be posted to GitHub var expectedRequestBody = GithubPullRequestCommentRequest .builder() .setBody( @@ -104,11 +111,53 @@ class GithubHelperTest { .setPath(".") .build(); + // Verify that the correct error comment was added to the PR verify(ghApi) .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber), Mockito.eq(expectedRequestBody)); } + @Test + void testHandleGithubWebhookValidation_NoErrors() { + // Set up test data for the PR + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; + + Commit testCommit = createTestCommit("abc123", "testUser", "test@example.com"); + + // Create webhook request simulating a GitHub PR event + GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + List<Commit> commits = List.of(testCommit); + + // Build a validation request as the service would receive it + ValidationRequest vr = ValidationRequest + .builder() + .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) + .setProvider(ProviderType.GITHUB) + .setCommits(commits) + .build(); + + // Create a mock successful validation response with no errors + ValidationResponse mockResponse = ValidationResponse + .builder() + .setStrictMode(false) + .setTrackedProject(true) + .setCommits(Map.of("abc123", CommitStatus.builder().build())) + .build(); + + // Configure mock to return our success response + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(mockResponse); + + // Execute the validation handler + helper.handleGithubWebhookValidation(request, vr, null); + + // Verify that no comment was added since there were no errors + verify(ghApi, Mockito.never()) + .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyInt(), + Mockito.any()); + } + /** * Creates a GitHub webhook request for testing purposes. * diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index ddeac400..88d68f97 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -31,6 +31,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; +import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment; import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.jboss.resteasy.reactive.RestResponse; @@ -97,12 +98,6 @@ public class MockGithubAPI implements GithubAPI { return Response.ok().build(); } - @Override - public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber, - GithubPullRequestCommentRequest comment) { - return Response.ok().build(); - } - @Override public RestResponse<List<GithubApplicationInstallationData>> getInstallations(BaseAPIParameters params, String bearer) { throw new UnsupportedOperationException("Unimplemented method 'getInstallations'"); @@ -118,4 +113,16 @@ public class MockGithubAPI implements GithubAPI { throw new UnsupportedOperationException("Unimplemented method 'getInstallationRepositories'"); } + @Override + public RestResponse<List<GithubPullRequestComment>> getComments(String bearer, String apiVersion, String organization, String repo, + int pullNumber, int perPage, int page) { + return RestResponse.ok(); + } + + @Override + public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber, + GithubPullRequestCommentRequest commentRequest) { + return Response.ok().build(); + } + } -- GitLab From bf4fa1afc921aeb39b99c8ab6ce2825b94c4e958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Thu, 24 Apr 2025 16:16:09 +0200 Subject: [PATCH 09/14] docs: adding comment to new class --- .../git/eca/api/models/GithubPullRequestComment.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java index fe0b5ff1..f3956f1c 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java @@ -14,6 +14,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.google.auto.value.AutoValue; +/** + * Model response for repos/{org}/{repo}/pulls/{pullNumber}/comments + */ @AutoValue @JsonDeserialize(builder = AutoValue_GithubPullRequestComment.Builder.class) public abstract class GithubPullRequestComment { -- GitLab From d8a8e7764a3bdae5b49e63b304bb3849bb40e2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Mon, 28 Apr 2025 10:25:59 +0200 Subject: [PATCH 10/14] refactor: moving getAllPullRequestComments to GithubHelper --- .../git/eca/api/GithubAPI.java | 32 ---------------- .../git/eca/helper/GithubHelper.java | 38 ++++++++++++++++++- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index 418bc631..90c4552d 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -167,36 +167,4 @@ public interface GithubAPI { @Path("installation/repositories") public Response getInstallationRepositories(@BeanParam BaseAPIParameters params, @HeaderParam(HttpHeaders.AUTHORIZATION) String bearer); - /** - * Recursively fetches all comments from a pull request by handling pagination manually. - * - * @param bearer GitHub bearer token - * @param apiVersion GitHub API version - * @param org organization name - * @param repo repository name - * @param prNumber pull request number - * @return List of all comments from the pull request - */ - default List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo, - int prNumber) { - List<GithubPullRequestComment> allComments = new ArrayList<>(); - int page = 1; - int perPage = 100; // GitHub's maximum items per page - - // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've - // reached the end - while (true) { - RestResponse<List<GithubPullRequestComment>> response = getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); - - List<GithubPullRequestComment> comments = response.getEntity(); - if (Objects.isNull(comments) || comments.isEmpty()) { - break; - } - - allComments.addAll(comments); - page++; - } - - return allComments; - } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 073032ed..66dc3dc7 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -57,6 +57,7 @@ import org.eclipsefoundation.persistence.model.RDBMSQuery; import org.eclipsefoundation.persistence.service.FilterService; import org.eclipsefoundation.utils.helper.DateTimeHelper; import org.eclipsefoundation.utils.helper.TransformationHelper; +import org.jboss.resteasy.reactive.RestResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.quarkus.qute.Location; @@ -481,6 +482,40 @@ public class GithubHelper { .build()); } + + /** + * Recursively fetches all comments from a pull request by handling pagination manually. + * + * @param bearer GitHub bearer token + * @param apiVersion GitHub API version + * @param org organization name + * @param repo repository name + * @param prNumber pull request number + * @return List of all comments from the pull request + */ + private List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo, + int prNumber) { + List<GithubPullRequestComment> allComments = new ArrayList<>(); + int page = 1; + int perPage = 100; // GitHub's maximum items per page + + // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've + // reached the end + while (true) { + RestResponse<List<GithubPullRequestComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); + + List<GithubPullRequestComment> comments = response.getEntity(); + if (Objects.isNull(comments) || comments.isEmpty()) { + break; + } + + allComments.addAll(comments); + page++; + } + + return allComments; + } + /** * This method posts a comment to the pull request detailing the validation errors and mentioning the usernames that need to take * action. @@ -503,8 +538,7 @@ public class GithubHelper { Integer pullRequestNumber = request.getPullRequest().getNumber(); // Get existing comments using pagination - List<GithubPullRequestComment> comments = ghApi - .getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber); + List<GithubPullRequestComment> comments = getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber); Set<String> nonMentionedUsers = usernames .stream() -- GitLab From f1be4a98fe9647aa8d274c3ed2fd720e8670f259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Mon, 19 May 2025 16:16:34 +0200 Subject: [PATCH 11/14] feat: using github issues API to send messages --- config/application/secret.properties.sample | 5 +- .../git/eca/api/GithubAPI.java | 35 ++-- ...stComment.java => GithubIssueComment.java} | 30 +-- .../api/models/GithubIssueCommentRequest.java | 60 ++++++ .../git/eca/config/GithubBotConfig.java | 37 ++++ .../git/eca/helper/GithubHelper.java | 85 ++++---- .../git/eca/helper/GithubHelperTest.java | 182 ++++++++++++------ .../git/eca/test/api/MockGithubAPI.java | 56 +++++- src/test/resources/application.properties | 3 + 9 files changed, 359 insertions(+), 134 deletions(-) rename src/main/java/org/eclipsefoundation/git/eca/api/models/{GithubPullRequestComment.java => GithubIssueComment.java} (63%) create mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java create mode 100644 src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java diff --git a/config/application/secret.properties.sample b/config/application/secret.properties.sample index 99f4d517..47d46680 100644 --- a/config/application/secret.properties.sample +++ b/config/application/secret.properties.sample @@ -16,4 +16,7 @@ eclipse.gitlab.access-token= ## Used to send mail through the EclipseFdn smtp connection quarkus.mailer.password=YOURGENERATEDAPPLICATIONPASSWORD -quarkus.mailer.username=YOUREMAIL@gmail.com \ No newline at end of file +quarkus.mailer.username=YOUREMAIL@gmail.com + +## github app bot id (this would be used to identify bot's comments) +eclipse.github.bot.id=s0m3ID \ No newline at end of file diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java index 90c4552d..a03a0048 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java @@ -11,9 +11,7 @@ */ package org.eclipsefoundation.git.eca.api; -import java.util.ArrayList; import java.util.List; -import java.util.Objects; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters; @@ -21,8 +19,8 @@ import org.eclipsefoundation.git.eca.api.models.GithubAccessToken; import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationData; import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.jboss.resteasy.reactive.RestResponse; @@ -30,7 +28,6 @@ import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; -import jakarta.ws.rs.PATCH; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -65,7 +62,7 @@ public interface GithubAPI { /** * Retrieves a list of commits related to the given pull request. - * + * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param repo the repo name that is being targeted @@ -80,7 +77,7 @@ public interface GithubAPI { /** * Posts an update to the Github API, using an access token to update a given pull requests commit status, targeted using the head sha. - * + * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param organization the organization that owns the targeted repo @@ -96,39 +93,39 @@ public interface GithubAPI { @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate); /** - * Adds a review comment to a pull request. + * Adds a comment to a pull request using the issues API. * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param organization the organization that owns the targeted repo * @param repo the repo name that is being targeted - * @param pullNumber the number of the pull request to which the comment will be added. - * @param commentRequest the review comment request containing the comment details + * @param issueNumber the number of the issue/PR to which the comment will be added. + * @param commentRequest the comment request containing the comment message * @return response indicating the result of the operation. */ @POST - @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") + @Path("repos/{org}/{repo}/issues/{issueNumber}/comments") public Response addComment(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, - @PathParam("pullNumber") int pullNumber, GithubPullRequestCommentRequest commentRequest); + @PathParam("issueNumber") int issueNumber, GithubIssueCommentRequest commentRequest); /** - * Lists comments on a pull request. + * Lists comments on an issue/pull request. * * @param bearer authorization header value, access token for application with access to repo * @param apiVersion the version of the GH API to target when making the request * @param organization the organization that owns the targeted repo * @param repo the repo name that is being targeted - * @param pullNumber the number of the pull request + * @param issueNumber the number of the issue/PR * @param perPage number of items to return per page, max 100 * @param page page number to return - * @return list of comments on the pull request + * @return list of comments on the issue/pull request */ @GET - @Path("repos/{org}/{repo}/pulls/{pullNumber}/comments") - public RestResponse<List<GithubPullRequestComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, + @Path("repos/{org}/{repo}/issues/{issueNumber}/comments") + public RestResponse<List<GithubIssueComment>> getComments(@HeaderParam(HttpHeaders.AUTHORIZATION) String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("org") String organization, @PathParam("repo") String repo, - @PathParam("pullNumber") int pullNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page); + @PathParam("issueNumber") int issueNumber, @QueryParam("per_page") int perPage, @QueryParam("page") int page); /** * Requires a JWT bearer token for the application to retrieve installations for. Returns a list of installations for the given @@ -158,7 +155,7 @@ public interface GithubAPI { /** * Returns a list of repositories for the given installation. - * + * * @param params the general params for requests, including pagination * @param bearer JWT bearer token for the target installation * @return list of repositories for the installation as a response for pagination diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java similarity index 63% rename from src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java rename to src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java index f3956f1c..a2c00ffe 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestComment.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java @@ -15,42 +15,48 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.google.auto.value.AutoValue; /** - * Model response for repos/{org}/{repo}/pulls/{pullNumber}/comments + * Model response for repos/{org}/{repo}/issues/{issueNumber}/comments */ @AutoValue -@JsonDeserialize(builder = AutoValue_GithubPullRequestComment.Builder.class) -public abstract class GithubPullRequestComment { +@JsonDeserialize(builder = AutoValue_GithubIssueComment.Builder.class) +public abstract class GithubIssueComment { public abstract String getBody(); - public abstract int getId(); + + public abstract Long getId(); + public abstract GithubUser getUser(); @AutoValue - @JsonDeserialize(builder = AutoValue_GithubPullRequestComment_GithubUser.Builder.class) + @JsonDeserialize(builder = AutoValue_GithubIssueComment_GithubUser.Builder.class) public abstract static class GithubUser { - public abstract int getId(); - + public abstract Long getId(); + public static Builder builder() { - return new AutoValue_GithubPullRequestComment_GithubUser.Builder(); + return new AutoValue_GithubIssueComment_GithubUser.Builder(); } @AutoValue.Builder @JsonPOJOBuilder(withPrefix = "set") public abstract static class Builder { - public abstract Builder setId(int id); + public abstract Builder setId(Long id); + public abstract GithubUser build(); } } public static Builder builder() { - return new AutoValue_GithubPullRequestComment.Builder(); + return new AutoValue_GithubIssueComment.Builder(); } @AutoValue.Builder @JsonPOJOBuilder(withPrefix = "set") public abstract static class Builder { public abstract Builder setBody(String body); - public abstract Builder setId(int id); + + public abstract Builder setId(Long id); + public abstract Builder setUser(GithubUser user); - public abstract GithubPullRequestComment build(); + + public abstract GithubIssueComment build(); } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java new file mode 100644 index 00000000..35b2adff --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.git.eca.api.models; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.google.auto.value.AutoValue; + +/** + * Model for creating a comment on a GitHub issue or pull request. + * Uses the issues API endpoint which works for both issues and PRs. + */ +@AutoValue +@JsonDeserialize(builder = AutoValue_GithubIssueCommentRequest.Builder.class) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public abstract class GithubIssueCommentRequest { + /** + * Gets the comment body text. + * + * @return the comment body text + */ + public abstract String getBody(); + + /** + * Creates a new builder for this class. + * + * @return a new builder instance + */ + public static Builder builder() { + return new AutoValue_GithubIssueCommentRequest.Builder(); + } + + @AutoValue.Builder + @JsonPOJOBuilder(withPrefix = "set") + public abstract static class Builder { + /** + * Sets the comment body text. + * + * @param body the comment text to set + * @return this builder instance + */ + public abstract Builder setBody(String body); + + /** + * Builds a new GithubIssueCommentRequest instance. + * + * @return the built instance + */ + public abstract GithubIssueCommentRequest build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java new file mode 100644 index 00000000..65a3a3d3 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.git.eca.config; + +import java.util.Optional; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * The intention of this class is to provide an static bot ID, given that this app only supports one installation, this way we can avoid + * having to retrieve this information from the API on every start, or storing it in the DB. + */ +@ApplicationScoped +public class GithubBotConfig { + + @ConfigProperty(name = "eclipse.github.bot.id") + Optional<Integer> botId; + + + /** + * Gets the GitHub bot ID. + * + * @return the bot ID as an Optional Integer + */ + public Optional<Integer> getBotId() { + return botId; + } +} diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 66dc3dc7..2d5efc78 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -2,7 +2,7 @@ * Copyright (c) 2023 Eclipse Foundation * * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 + * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Martin Lowe <martin.lowe@eclipse-foundation.org> @@ -23,6 +23,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; + import org.apache.commons.lang3.StringUtils; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; @@ -32,10 +33,11 @@ import org.eclipsefoundation.git.eca.api.models.GithubApplicationInstallationDat import org.eclipsefoundation.git.eca.api.models.GithubCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; +import org.eclipsefoundation.git.eca.config.GithubBotConfig; import org.eclipsefoundation.git.eca.config.WebhooksConfig; import org.eclipsefoundation.git.eca.dto.CommitValidationStatus; import org.eclipsefoundation.git.eca.dto.GithubApplicationInstallation; @@ -60,6 +62,7 @@ import org.eclipsefoundation.utils.helper.TransformationHelper; import org.jboss.resteasy.reactive.RestResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import io.quarkus.qute.Location; import io.quarkus.qute.Template; import jakarta.enterprise.context.ApplicationScoped; @@ -84,6 +87,9 @@ public class GithubHelper { @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28") String apiVersion; + @Inject + GithubBotConfig botConfig; + @Inject WebhooksConfig webhooksConfig; @@ -114,7 +120,7 @@ public class GithubHelper { /** * Using the wrapper and the passed unique information about a Github validation instance, lookup or create the tracking request and * validate the data. - * + * * @param wrapper the wrapper for the current request * @param org the name of the GH organization * @param repoName the slug of the repo that has the PR to be validated @@ -176,7 +182,7 @@ public class GithubHelper { /** * Generate a ValidationRequest object based on data pulled from Github, grabbing commits from the noted pull request using the * installation ID for access/authorization. - * + * * @param installationId the ECA app installation ID for the organization * @param repositoryFullName the full name of the repository where the PR resides * @param pullRequestNumber the pull request number that is being validated @@ -234,7 +240,7 @@ public class GithubHelper { /** * Process the current 'merge_group' request and create a commit status for the HEAD SHA. As commits need to pass branch rules including * the existing ECA check, we don't need to check the commits here. - * + * * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit * status of the resources */ @@ -258,7 +264,7 @@ public class GithubHelper { /** * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE logging for more * info on the states of the validation for more information - * + * * @param request information about the request from the GH webhook on what resource requested revalidation. Used to target the commit * status of the resources * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation logic. @@ -309,7 +315,7 @@ public class GithubHelper { /** * Searches for a commit in the validation request based on its hash value. - * + * * @param vr The validation request containing the list of commits to search * @param hash The hash value to search for * @return An Optional containing the matching Commit if found, or empty if not found @@ -321,7 +327,7 @@ public class GithubHelper { /** * Shortcut method that will retrieve existing GH tracking info, create new entries if missing, and will update the state of existing * requests as well. - * + * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pr the pull request targeted by the validation request. @@ -336,7 +342,7 @@ public class GithubHelper { /** * Checks if the Github tracking is present for the current request, and if missing will generate a new record and save it. - * + * * @param tracking the optional tracking entry for the current request * @param request the pull request that is being validated * @param installationId the ECA app installation ID for the current request @@ -382,7 +388,7 @@ public class GithubHelper { /** * Attempts to retrieve a webhook tracking record given the installation, repository, and pull request number. - * + * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pullRequestNumber the pull request number that is being processed currently @@ -403,7 +409,7 @@ public class GithubHelper { * Using the configured GitHub application JWT and application ID, fetches all active installations and updates the DB cache containing * the installation data. After all records are updated, the starting timestamp is used to delete stale records that were updated before * the starting time. - * + * * This does not use locking, but with the way that updates are done to look for long stale entries, multiple concurrent runs would not * lead to a loss of data/integrity. */ @@ -444,7 +450,7 @@ public class GithubHelper { /** * Simple helper method so we don't have to repeat static strings in multiple places. - * + * * @param fullRepoName the full repo name including org for the target PR. * @return the full repo URL on Github for the request */ @@ -454,7 +460,7 @@ public class GithubHelper { /** * Sends off a POST to update the commit status given a context for the current PR. - * + * * @param request the current webhook update request * @param state the state to set the status to * @param fingerprint the internal unique string for the set of commits being processed @@ -482,7 +488,6 @@ public class GithubHelper { .build()); } - /** * Recursively fetches all comments from a pull request by handling pagination manually. * @@ -493,23 +498,25 @@ public class GithubHelper { * @param prNumber pull request number * @return List of all comments from the pull request */ - private List<GithubPullRequestComment> getAllPullRequestComments(String bearer, String apiVersion, String org, String repo, - int prNumber) { - List<GithubPullRequestComment> allComments = new ArrayList<>(); - int page = 1; + private List<GithubIssueComment> getAllPullRequestCommentsByUserId(String bearer, String apiVersion, String org, String repo, + int prNumber, int userId) { int perPage = 100; // GitHub's maximum items per page + int page = 1; // Start from the first page + List<GithubIssueComment> allComments = new ArrayList<>(); // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've // reached the end while (true) { - RestResponse<List<GithubPullRequestComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); + RestResponse<List<GithubIssueComment>> response = ghApi + .getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); - List<GithubPullRequestComment> comments = response.getEntity(); + List<GithubIssueComment> comments = response.getEntity(); if (Objects.isNull(comments) || comments.isEmpty()) { break; } - allComments.addAll(comments); + // We only want the comments made by the bot user + allComments.addAll(comments.stream().filter(comment -> comment.getUser().getId() == userId).collect(Collectors.toList())); page++; } @@ -537,15 +544,21 @@ public class GithubHelper { String repositoryName = request.getRepository().getName(); Integer pullRequestNumber = request.getPullRequest().getNumber(); - // Get existing comments using pagination - List<GithubPullRequestComment> comments = getAllPullRequestComments(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber); + Set<String> nonMentionedUsers = Set.copyOf(usernames); - Set<String> nonMentionedUsers = usernames - .stream() - .filter(username -> comments - .stream() - .noneMatch(comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username)))) - .collect(Collectors.toSet()); + if (botConfig.getBotId().isPresent()) { + // Get existing comments using pagination + List<GithubIssueComment> comments = getAllPullRequestCommentsByUserId(ghBearerString, apiVersion, login, repositoryName, + pullRequestNumber, botConfig.getBotId().get()); + + nonMentionedUsers = usernames + .stream() + .filter(username -> comments + .stream() + .noneMatch( + comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username)))) + .collect(Collectors.toSet()); + } // If all the users have already been mentioned, skip commenting if (nonMentionedUsers.isEmpty()) { @@ -553,11 +566,9 @@ public class GithubHelper { return; } - GithubPullRequestCommentRequest comment = GithubPullRequestCommentRequest + GithubIssueCommentRequest comment = GithubIssueCommentRequest .builder() - .setBody(failureMessage.data("reasons", errors).data("usernames", usernames).render()) - .setCommitId(request.getPullRequest().getHead().getSha()) - .setPath(".") + .setBody(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render()) .build(); LOGGER @@ -573,7 +584,7 @@ public class GithubHelper { /** * Wraps a nullable value fetch to handle errors and will return null if the value can't be retrieved. - * + * * @param supplier the method with potentially nullable values * @return the value if it can be found, or null */ @@ -588,7 +599,7 @@ public class GithubHelper { /** * Validates required fields for processing requests. - * + * * @param installationId the installation ID for the ECA app in the given repository * @param repositoryFullName the full repository name for the target repo, e.g. eclipse/jetty * @param pullRequestNumber the pull request number that is being processed currently @@ -618,7 +629,7 @@ public class GithubHelper { /** * Converts the raw installation data from Github into a short record to be persisted to database as a form of persistent caching. * Checks database for existing record, and returns record with a touched date for existing entries. - * + * * @param ghInstallation raw Github installation record for current application * @return the new or updated installation record to be persisted to the database. */ diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 79da0acc..91e08bd9 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -2,7 +2,7 @@ * Copyright (c) 2025 Eclipse Foundation * * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 + * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 @@ -10,7 +10,8 @@ package org.eclipsefoundation.git.eca.helper; -import static org.mockito.Mockito.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.net.URI; @@ -22,7 +23,7 @@ import java.util.Map; import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipsefoundation.git.eca.api.GithubAPI; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.model.Commit; import org.eclipsefoundation.git.eca.model.CommitStatus; @@ -32,11 +33,12 @@ import org.eclipsefoundation.git.eca.model.ValidationResponse; import org.eclipsefoundation.git.eca.namespace.APIStatusCode; import org.eclipsefoundation.git.eca.namespace.ProviderType; import org.eclipsefoundation.git.eca.service.ValidationService; +import org.eclipsefoundation.git.eca.test.api.MockGithubAPI; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.mockito.InjectSpy; import jakarta.inject.Inject; @QuarkusTest @@ -46,14 +48,31 @@ class GithubHelperTest { GithubHelper helper; @RestClient - @InjectSpy + @Inject GithubAPI ghApi; @InjectMock ValidationService validationService; + /** + * Clean up the test environment before each test. This is important for Quarkus tests as beans maintain state between tests. + */ + @BeforeEach + void setUp() { + // Clean up the MockGithubAPI state if it's our mock implementation + if (ghApi instanceof MockGithubAPI mockGithubApi) { + mockGithubApi.cleanState(); + } + + // Reset mock interactions and stubbing + Mockito.reset(validationService); + } + + /** + * Test validation when multiple commits have ECA validation errors. Verifies that all users with invalid commits are properly notified. + */ @Test - void testHandleGithubWebhookValidation_WithErrors() { + void testHandleGithubWebhookValidation_MultipleCommitErrors() { // Set up test data for the PR String orgName = "test-org"; String repoName = "test-repo"; @@ -62,17 +81,9 @@ class GithubHelperTest { Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); Commit testCommit2 = createTestCommit("def456", "testUser2", "test2@example.com"); - // Create webhook request simulating a GitHub PR event + // Create webhook request and validation request GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); - List<Commit> commits = List.of(testCommit1, testCommit2); - - // Build a validation request as the service would receive it - ValidationRequest vr = ValidationRequest - .builder() - .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) - .setProvider(ProviderType.GITHUB) - .setCommits(commits) - .build(); + ValidationRequest vr = createValidationRequest(orgName, repoName, List.of(testCommit1, testCommit2)); // Create a mock validation response with multiple errors ValidationResponse mockResponse = createErrorValidationResponse( @@ -84,37 +95,50 @@ class GithubHelperTest { // Execute the validation handler helper.handleGithubWebhookValidation(request, vr, null); - // Prepare the expected error comment that should be posted to GitHub - var expectedRequestBody = GithubPullRequestCommentRequest - .builder() - .setBody( - """ - Hi @testUser @testUser2 — thank you for your contribution! + // Verify that the correct error comment was added to the PR + verifyErrorComment(orgName, repoName, prNumber, List.of("testUser", "testUser2"), List.of("Missing ECA for user", "Another error")); + } - The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons: + /** + * Test validation when new commits are added to a PR where some users were previously notified. Verifies that only new users are + * notified of validation errors. + */ + @Test + void testHandleGithubWebhookValidation_OnlyNewUsersNotified() { + // Set up test data + String orgName = "test-org"; + String repoName = "test-repo"; + int prNumber = 42; - - Missing ECA for user - - Another error + // First validation with initial commits + Commit testCommit1 = createTestCommit("abc123", "testUser", "test@example.com"); + GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); + ValidationRequest initialVr = createValidationRequest(orgName, repoName, List.of(testCommit1)); - To resolve this, please: + // Mock initial validation response + ValidationResponse initialMockResponse = createErrorValidationResponse(Map.of(testCommit1, List.of("Missing ECA for user"))); + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(initialMockResponse); - 1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca - 1. Ensure your GitHub username is linked to your Eclipse account - 1. Complete and submit the ECA form + // Execute initial validation + helper.handleGithubWebhookValidation(request, initialVr, null); - Once done, push a new commit (or rebase) to re-trigger the ECA validation. + // Verify initial error comment + verifyErrorComment(orgName, repoName, prNumber, List.of("testUser"), List.of("Missing ECA for user")); - If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). + // Second validation with new commit + Commit testCommit3 = createTestCommit("ghi789", "testUser3", "test3@example.com"); + ValidationRequest newVr = createValidationRequest(orgName, repoName, List.of(testCommit1, testCommit3)); - Thanks again for your contribution!""") - .setCommitId(request.getPullRequest().getHead().getSha()) - .setPath(".") - .build(); + // Mock new validation response + ValidationResponse newMockResponse = createErrorValidationResponse( + Map.of(testCommit1, List.of("Missing ECA for user"), testCommit3, List.of("Missing ECA for user", "Another error"))); + when(validationService.validateIncomingRequest(Mockito.any(), Mockito.any())).thenReturn(newMockResponse); - // Verify that the correct error comment was added to the PR - verify(ghApi) - .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.eq(orgName), Mockito.eq(repoName), Mockito.eq(prNumber), - Mockito.eq(expectedRequestBody)); + // Execute new validation + helper.handleGithubWebhookValidation(request, newVr, null); + + // Verify that only the new user is notified + verifyErrorComment(orgName, repoName, prNumber, List.of("testUser3"), List.of("Missing ECA for user", "Another error")); } @Test @@ -128,15 +152,7 @@ class GithubHelperTest { // Create webhook request simulating a GitHub PR event GithubWebhookRequest request = createTestWebhookRequest("12345", repoName, orgName, prNumber, "abc123"); - List<Commit> commits = List.of(testCommit); - - // Build a validation request as the service would receive it - ValidationRequest vr = ValidationRequest - .builder() - .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) - .setProvider(ProviderType.GITHUB) - .setCommits(commits) - .build(); + ValidationRequest vr = createValidationRequest(orgName, repoName, List.of(testCommit)); // Create a mock successful validation response with no errors ValidationResponse mockResponse = ValidationResponse @@ -152,15 +168,14 @@ class GithubHelperTest { // Execute the validation handler helper.handleGithubWebhookValidation(request, vr, null); - // Verify that no comment was added since there were no errors - verify(ghApi, Mockito.never()) - .addComment(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyInt(), - Mockito.any()); + // Verify no comments were added + var comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity(); + assertTrue(comments.isEmpty(), "No comments should be added when there are no errors"); } /** * Creates a GitHub webhook request for testing purposes. - * + * * @param installationId The installation ID * @param repoName The repository name * @param orgName The organization name @@ -191,7 +206,7 @@ class GithubHelperTest { /** * Creates a test commit with specified user details. - * + * * @param commitHash The commit hash * @param userName The user's name * @param userEmail The user's email @@ -212,7 +227,7 @@ class GithubHelperTest { /** * Creates a validation response with an error status. - * + * * @param commitHash The commit hash to associate the error with * @param errorMessage The error message * @return ValidationResponse instance @@ -233,4 +248,61 @@ class GithubHelperTest { .build(); } + /** + * Creates a validation request for testing. + * + * @param orgName The organization name + * @param repoName The repository name + * @param commits The list of commits to validate + * @return ValidationRequest instance + */ + private ValidationRequest createValidationRequest(String orgName, String repoName, List<Commit> commits) { + return ValidationRequest + .builder() + .setRepoUrl(URI.create("https://github.com/" + orgName + "/" + repoName)) + .setProvider(ProviderType.GITHUB) + .setCommits(commits) + .build(); + } + + /** + * Verifies that an error comment was posted to GitHub with the expected content. + * + * @param orgName The organization name + * @param repoName The repository name + * @param prNumber The pull request number + * @param usernames List of GitHub usernames to be mentioned + * @param errors List of error messages + */ + private void verifyErrorComment(String orgName, String repoName, int prNumber, List<String> usernames, List<String> errors) { + String mentions = usernames.stream().map(user -> "@" + user).collect(java.util.stream.Collectors.joining(" ")); + String errorBullets = errors.stream().map(error -> "- " + error).collect(java.util.stream.Collectors.joining("\n")); + + String expectedBody = String + .format(""" + Hi %s — thank you for your contribution! + + The [Eclipse Contributor Agreement (ECA)](https://www.eclipse.org/legal/eca/) check has failed for this pull request due to one of the following reasons: + + %s + + To resolve this, please: + + 1. Sign in or create an Eclipse Foundation account: https://accounts.eclipse.org/user/eca + 1. Ensure your GitHub username is linked to your Eclipse account + 1. Complete and submit the ECA form + + Once done, push a new commit (or rebase) to re-trigger the ECA validation. + + If you believe you've already completed these steps, please double-check your account settings or report an issue to [Eclipse Foundation Helpdesk](https://gitlab.eclipse.org/eclipsefdn/helpdesk). + + Thanks again for your contribution!""", + mentions, errorBullets); + + // Get the comments and verify content + List<GithubIssueComment> comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity(); + assertEquals(1, comments.stream().filter(comment -> comment.getBody().equals(expectedBody)).toList().size(), + "Expected one comment with the expected body"); + } + } diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 88d68f97..6148aa25 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -2,7 +2,7 @@ * Copyright (c) 2023 Eclipse Foundation. * * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 + * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org> @@ -12,15 +12,13 @@ package org.eclipsefoundation.git.eca.test.api; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.ws.rs.core.Response; - import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipsefoundation.core.service.APIMiddleware.BaseAPIParameters; import org.eclipsefoundation.git.eca.api.GithubAPI; @@ -31,12 +29,16 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.CommitData; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestComment; -import org.eclipsefoundation.git.eca.api.models.GithubPullRequestCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; +import org.eclipsefoundation.git.eca.config.GithubBotConfig; import org.jboss.resteasy.reactive.RestResponse; import io.quarkus.test.Mock; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; @Mock @RestClient @@ -45,10 +47,15 @@ public class MockGithubAPI implements GithubAPI { Map<String, Map<Integer, List<GithubCommit>>> commits; Map<String, Map<String, String>> commitStatuses; + Map<String, Map<Integer, List<GithubIssueComment>>> comments; + + @Inject + GithubBotConfig githubBotConfig; public MockGithubAPI() { this.commitStatuses = new HashMap<>(); this.commits = new HashMap<>(); + this.comments = new HashMap<>(); this.commits .put("eclipsefdn/sample", Map @@ -114,15 +121,44 @@ public class MockGithubAPI implements GithubAPI { } @Override - public RestResponse<List<GithubPullRequestComment>> getComments(String bearer, String apiVersion, String organization, String repo, + public RestResponse<List<GithubIssueComment>> getComments(String bearer, String apiVersion, String organization, String repo, int pullNumber, int perPage, int page) { - return RestResponse.ok(); + + // to avoid loops when loading all comments + if (page > 1) { + return RestResponse.ok(Collections.emptyList()); + } + + String repoFullName = organization + '/' + repo; + Map<Integer, List<GithubIssueComment>> repoMap = comments.getOrDefault(repoFullName, Collections.emptyMap()); + List<GithubIssueComment> repoComments = repoMap.getOrDefault(pullNumber, Collections.emptyList()); + return RestResponse.ok(repoComments); } @Override - public Response addComment(String bearer, String apiVersion, String organization, String repo, int pullNumber, - GithubPullRequestCommentRequest commentRequest) { + public Response addComment(String bearer, String apiVersion, String organization, String repo, int issueNumber, + GithubIssueCommentRequest commentRequest) { + String repoFullName = organization + '/' + repo; + GithubIssueComment comment = GithubIssueComment + .builder() + .setId(0) + .setBody(commentRequest.getBody()) + .setUser(GithubIssueComment.GithubUser.builder().setId(githubBotConfig.getBotId().orElse(0)).build()) + .build(); + + comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment); + return Response.ok().build(); } + /** + * Cleans up the internal state of the mock. + * This method should be called before each test to ensure a clean state. + */ + public void cleanState() { + this.commits = new HashMap<>(); + this.commitStatuses = new HashMap<>(); + this.comments = new HashMap<>(); + } + } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index bd6e17d8..4a6a4075 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -44,3 +44,6 @@ eclipse.gitlab.access-token=token_val ## Disable private project scan in test mode eclipse.scheduled.private-project.enabled=false + +## GitHub App bot ID +eclipse.github.bot.id=111111 \ No newline at end of file -- GitLab From 8b051c71ed1da9ee806df2bb014a67bec7e05a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Mon, 19 May 2025 16:26:10 +0200 Subject: [PATCH 12/14] chore: removing unused class --- .../GithubPullRequestCommentRequest.java | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java deleted file mode 100644 index 2690fe80..00000000 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubPullRequestCommentRequest.java +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright (c) 2025 Eclipse Foundation - * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.eclipsefoundation.git.eca.api.models; - -import jakarta.annotation.Nullable; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.google.auto.value.AutoValue; - -/** - * Model for creating a review comment on a pull request. - */ -@AutoValue -@JsonDeserialize(builder = AutoValue_GithubPullRequestCommentRequest.Builder.class) -public abstract class GithubPullRequestCommentRequest { - public abstract String getBody(); - - public abstract String getCommitId(); - - public abstract String getPath(); - - @Nullable - public abstract Integer getLine(); - - @Nullable - public abstract String getSide(); - - @Nullable - public abstract Integer getStartLine(); - - @Nullable - public abstract String getStartSide(); - - public static Builder builder() { - return new AutoValue_GithubPullRequestCommentRequest.Builder(); - } - - @AutoValue.Builder - @JsonPOJOBuilder(withPrefix = "set") - public abstract static class Builder { - public abstract Builder setBody(String body); - - public abstract Builder setCommitId(String commitId); - - public abstract Builder setPath(String path); - - public abstract Builder setLine(@Nullable Integer line); - - public abstract Builder setSide(@Nullable String side); - - public abstract Builder setStartLine(@Nullable Integer startLine); - - public abstract Builder setStartSide(@Nullable String startSide); - - public abstract GithubPullRequestCommentRequest build(); - } -} -- GitLab From 23e3f8b4a0d22ef3722971e416d9b41a34c35daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Mon, 19 May 2025 16:30:08 +0200 Subject: [PATCH 13/14] fix: updating gh IssueComment usage --- .../org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index 6148aa25..db5b52ef 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -141,9 +141,9 @@ public class MockGithubAPI implements GithubAPI { String repoFullName = organization + '/' + repo; GithubIssueComment comment = GithubIssueComment .builder() - .setId(0) + .setId(Long.valueOf(0)) .setBody(commentRequest.getBody()) - .setUser(GithubIssueComment.GithubUser.builder().setId(githubBotConfig.getBotId().orElse(0)).build()) + .setUser(GithubIssueComment.GithubUser.builder().setId(Long.valueOf(githubBotConfig.getBotId().orElse(0))).build()) .build(); comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment); -- GitLab From f9a7456222733722ef1438b40161f969ae2271c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= <jordi.gomez@eclipse-foundation.org> Date: Wed, 21 May 2025 16:13:41 +0200 Subject: [PATCH 14/14] feat: migrating some Autovalue classes to records + small changes --- pom.xml | 34 +++++++++-- .../eca/api/models/GithubIssueComment.java | 61 +++++-------------- .../api/models/GithubIssueCommentRequest.java | 44 +------------ .../git/eca/config/GithubBotConfig.java | 23 ++----- .../git/eca/helper/GithubHelper.java | 43 +++++++------ .../git/eca/helper/GithubHelperTest.java | 2 +- .../git/eca/test/api/MockGithubAPI.java | 11 ++-- 7 files changed, 83 insertions(+), 135 deletions(-) diff --git a/pom.xml b/pom.xml index ec754369..b712261f 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,18 @@ <?xml version="1.0" encoding="UTF-8"?> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> +<!-- + Copyright (c) 2020 + 2025 Eclipse Foundation + + This program and the accompanying materials are made + available under the terms of the Eclipse Public License 2.0 + which is available at https://www.eclipse.org/legal/epl-2.0/ + + SPDX-License-Identifier: EPL-2.0 +--> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.eclipsefoundation</groupId> <artifactId>git-eca</artifactId> @@ -15,7 +28,7 @@ <quarkus.platform.version>3.15.4</quarkus.platform.version> <surefire-plugin.version>3.3.1</surefire-plugin.version> <maven.compiler.parameters>true</maven.compiler.parameters> - <eclipse-api-version>1.2.3</eclipse-api-version> + <eclipse-api-version>1.2.5</eclipse-api-version> <auto-value.version>1.10.4</auto-value.version> <org.mapstruct.version>1.5.5.Final</org.mapstruct.version> <sonar.sources>src/main</sonar.sources> @@ -75,8 +88,8 @@ <artifactId>quarkus-smallrye-jwt</artifactId> </dependency> <dependency> - <groupId>io.quarkus</groupId> - <artifactId>quarkus-mailer</artifactId> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-mailer</artifactId> </dependency> <!-- Annotation preprocessors - reduce all of the boiler plate --> @@ -97,6 +110,12 @@ <version>${org.mapstruct.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>io.soabase.record-builder</groupId> + <artifactId>record-builder-processor</artifactId> + <version>45</version> + <scope>provided</scope> + </dependency> <!-- Test requirements --> <dependency> @@ -168,6 +187,11 @@ <artifactId>mapstruct-processor</artifactId> <version>${org.mapstruct.version}</version> </path> + <path> + <groupId>io.soabase.record-builder</groupId> + <artifactId>record-builder-processor</artifactId> + <version>45</version> + </path> </annotationProcessorPaths> </configuration> </plugin> @@ -184,4 +208,4 @@ </plugin> </plugins> </build> -</project> +</project> \ No newline at end of file diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java index a2c00ffe..91a798a7 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueComment.java @@ -10,53 +10,24 @@ package org.eclipsefoundation.git.eca.api.models; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.google.auto.value.AutoValue; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.soabase.recordbuilder.core.RecordBuilder; /** * Model response for repos/{org}/{repo}/issues/{issueNumber}/comments */ -@AutoValue -@JsonDeserialize(builder = AutoValue_GithubIssueComment.Builder.class) -public abstract class GithubIssueComment { - public abstract String getBody(); - - public abstract Long getId(); - - public abstract GithubUser getUser(); - - @AutoValue - @JsonDeserialize(builder = AutoValue_GithubIssueComment_GithubUser.Builder.class) - public abstract static class GithubUser { - public abstract Long getId(); - - public static Builder builder() { - return new AutoValue_GithubIssueComment_GithubUser.Builder(); - } - - @AutoValue.Builder - @JsonPOJOBuilder(withPrefix = "set") - public abstract static class Builder { - public abstract Builder setId(Long id); - - public abstract GithubUser build(); - } - } - - public static Builder builder() { - return new AutoValue_GithubIssueComment.Builder(); - } - - @AutoValue.Builder - @JsonPOJOBuilder(withPrefix = "set") - public abstract static class Builder { - public abstract Builder setBody(String body); - - public abstract Builder setId(Long id); - - public abstract Builder setUser(GithubUser user); - - public abstract GithubIssueComment build(); - } +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@RecordBuilder +public record GithubIssueComment( + String body, + Long id, + GithubUser user +) { + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @RecordBuilder + public static record GithubUser( + Long id + ) {} } diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java index 35b2adff..b6ec978d 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubIssueCommentRequest.java @@ -10,51 +10,13 @@ package org.eclipsefoundation.git.eca.api.models; import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.google.auto.value.AutoValue; +import io.soabase.recordbuilder.core.RecordBuilder; /** * Model for creating a comment on a GitHub issue or pull request. * Uses the issues API endpoint which works for both issues and PRs. */ -@AutoValue -@JsonDeserialize(builder = AutoValue_GithubIssueCommentRequest.Builder.class) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public abstract class GithubIssueCommentRequest { - /** - * Gets the comment body text. - * - * @return the comment body text - */ - public abstract String getBody(); - - /** - * Creates a new builder for this class. - * - * @return a new builder instance - */ - public static Builder builder() { - return new AutoValue_GithubIssueCommentRequest.Builder(); - } - - @AutoValue.Builder - @JsonPOJOBuilder(withPrefix = "set") - public abstract static class Builder { - /** - * Sets the comment body text. - * - * @param body the comment text to set - * @return this builder instance - */ - public abstract Builder setBody(String body); - - /** - * Builds a new GithubIssueCommentRequest instance. - * - * @return the built instance - */ - public abstract GithubIssueCommentRequest build(); - } -} \ No newline at end of file +@RecordBuilder +public record GithubIssueCommentRequest(String body) {} \ No newline at end of file diff --git a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java index 65a3a3d3..8d26a3e1 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java +++ b/src/main/java/org/eclipsefoundation/git/eca/config/GithubBotConfig.java @@ -9,29 +9,14 @@ */ package org.eclipsefoundation.git.eca.config; +import io.smallrye.config.ConfigMapping; import java.util.Optional; -import org.eclipse.microprofile.config.inject.ConfigProperty; - -import jakarta.enterprise.context.ApplicationScoped; - /** * The intention of this class is to provide an static bot ID, given that this app only supports one installation, this way we can avoid * having to retrieve this information from the API on every start, or storing it in the DB. */ -@ApplicationScoped -public class GithubBotConfig { - - @ConfigProperty(name = "eclipse.github.bot.id") - Optional<Integer> botId; - - - /** - * Gets the GitHub bot ID. - * - * @return the bot ID as an Optional Integer - */ - public Optional<Integer> getBotId() { - return botId; - } +@ConfigMapping(prefix = "eclipse.github.bot") +public interface GithubBotConfig { + Optional<Long> id(); } diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java index 2d5efc78..0f5e15a2 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java +++ b/src/main/java/org/eclipsefoundation/git/eca/helper/GithubHelper.java @@ -35,6 +35,7 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.ParentCommit; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequestBuilder; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.eclipsefoundation.git.eca.config.GithubBotConfig; @@ -153,7 +154,7 @@ public class GithubHelper { } LOGGER.debug("Found {} commits for '{}#{}'", commitShas.size(), fullRepoName, prNo); - // retrieve the webhook tracking info, or generate an entry to track this PR + // retrieve the webhook tracking info, or generate an entry to track this PR if it's missing. GithubWebhookTracking updatedTracking = retrieveAndUpdateTrackingInformation(wrapper, installationId, org, repoName, prResponse.get()); if (updatedTracking == null) { @@ -472,9 +473,12 @@ public class GithubHelper { throw new IllegalStateException("Pull request should not be null when handling validation"); } - LOGGER - .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + if (LOGGER.isTraceEnabled()) { + LOGGER + .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + } + ghApi .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion, request.getRepository().getOwner().getLogin(), request.getRepository().getName(), pr.getHead().getSha(), @@ -499,24 +503,23 @@ public class GithubHelper { * @return List of all comments from the pull request */ private List<GithubIssueComment> getAllPullRequestCommentsByUserId(String bearer, String apiVersion, String org, String repo, - int prNumber, int userId) { + int prNumber, Long userId) { int perPage = 100; // GitHub's maximum items per page int page = 1; // Start from the first page List<GithubIssueComment> allComments = new ArrayList<>(); // Given that there's no pagination in the response, we need to query until we get an empty response, that would mean that we've // reached the end - while (true) { - RestResponse<List<GithubIssueComment>> response = ghApi - .getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); + while (page < 50) { + RestResponse<List<GithubIssueComment>> response = ghApi.getComments(bearer, apiVersion, org, repo, prNumber, perPage, page); List<GithubIssueComment> comments = response.getEntity(); - if (Objects.isNull(comments) || comments.isEmpty()) { + if (comments == null || comments.isEmpty()) { break; } // We only want the comments made by the bot user - allComments.addAll(comments.stream().filter(comment -> comment.getUser().getId() == userId).collect(Collectors.toList())); + allComments.addAll(comments.stream().filter(comment -> comment.user().id() == userId).collect(Collectors.toList())); page++; } @@ -546,17 +549,17 @@ public class GithubHelper { Set<String> nonMentionedUsers = Set.copyOf(usernames); - if (botConfig.getBotId().isPresent()) { + if (botConfig.id().isPresent()) { // Get existing comments using pagination List<GithubIssueComment> comments = getAllPullRequestCommentsByUserId(ghBearerString, apiVersion, login, repositoryName, - pullRequestNumber, botConfig.getBotId().get()); + pullRequestNumber, botConfig.id().get()); nonMentionedUsers = usernames .stream() .filter(username -> comments .stream() .noneMatch( - comment -> Objects.requireNonNullElse(comment.getBody(), "").contains(String.format("@%s", username)))) + comment -> Objects.requireNonNullElse(comment.body(), "").contains(String.format("@%s", username)))) .collect(Collectors.toSet()); } @@ -566,18 +569,20 @@ public class GithubHelper { return; } - GithubIssueCommentRequest comment = GithubIssueCommentRequest + GithubIssueCommentRequest comment = GithubIssueCommentRequestBuilder .builder() - .setBody(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render()) + .body(failureMessage.data("reasons", errors).data("usernames", nonMentionedUsers).render()) .build(); - LOGGER - .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), - jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + if (LOGGER.isTraceEnabled()) { + LOGGER + .trace("Generated access token for installation {}: {}", request.getInstallation().getId(), + jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken()); + } LOGGER .trace("Adding new comment to PR {} in repo {}/{}: {}", pullRequestNumber, TransformationHelper.formatLog(login), - TransformationHelper.formatLog(repositoryName), comment.getBody()); + TransformationHelper.formatLog(repositoryName), comment.body()); ghApi.addComment(ghBearerString, apiVersion, login, repositoryName, pullRequestNumber, comment); } diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java index 91e08bd9..7a8d3dd9 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java +++ b/src/test/java/org/eclipsefoundation/git/eca/helper/GithubHelperTest.java @@ -301,7 +301,7 @@ class GithubHelperTest { // Get the comments and verify content List<GithubIssueComment> comments = ghApi.getComments("", "", orgName, repoName, prNumber, 30, 1).getEntity(); - assertEquals(1, comments.stream().filter(comment -> comment.getBody().equals(expectedBody)).toList().size(), + assertEquals(1, comments.stream().filter(comment -> comment.body().equals(expectedBody)).toList().size(), "Expected one comment with the expected body"); } diff --git a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java index db5b52ef..011d8755 100644 --- a/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java +++ b/src/test/java/org/eclipsefoundation/git/eca/test/api/MockGithubAPI.java @@ -30,6 +30,8 @@ import org.eclipsefoundation.git.eca.api.models.GithubCommit.GitCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommit.GithubCommitUser; import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest; import org.eclipsefoundation.git.eca.api.models.GithubIssueComment; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentBuilder; +import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentGithubUserBuilder; import org.eclipsefoundation.git.eca.api.models.GithubIssueCommentRequest; import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest.PullRequest; import org.eclipsefoundation.git.eca.config.GithubBotConfig; @@ -139,11 +141,10 @@ public class MockGithubAPI implements GithubAPI { public Response addComment(String bearer, String apiVersion, String organization, String repo, int issueNumber, GithubIssueCommentRequest commentRequest) { String repoFullName = organization + '/' + repo; - GithubIssueComment comment = GithubIssueComment - .builder() - .setId(Long.valueOf(0)) - .setBody(commentRequest.getBody()) - .setUser(GithubIssueComment.GithubUser.builder().setId(Long.valueOf(githubBotConfig.getBotId().orElse(0))).build()) + GithubIssueComment comment = GithubIssueCommentBuilder.builder() + .id(0L) + .body(commentRequest.body()) + .user(GithubIssueCommentGithubUserBuilder.builder().id(githubBotConfig.id().orElse(0L)).build()) .build(); comments.computeIfAbsent(repoFullName, k -> new HashMap<>()).computeIfAbsent(issueNumber, k -> new ArrayList<>()).add(comment); -- GitLab