From f4e29a3fd6a576cd7cd2940ab2a43f8aef14a369 Mon Sep 17 00:00:00 2001 From: Philipp Dolif Date: Fri, 19 Aug 2022 11:14:32 +0200 Subject: [PATCH 1/3] Add GitHubAppHelper #17 --- api-testing-bot/build.gradle | 5 + .../apiTestingBot/util/GitHubAppHelper.java | 105 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/GitHubAppHelper.java diff --git a/api-testing-bot/build.gradle b/api-testing-bot/build.gradle index 3a5466d..e9eff99 100644 --- a/api-testing-bot/build.gradle +++ b/api-testing-bot/build.gradle @@ -29,6 +29,11 @@ dependencies { implementation "com.googlecode.json-simple:json-simple:1.1.1" implementation "com.konghq:unirest-java:3.13.10" implementation "i5:las2peer-api-test-model:0.1.9" + + // GitHub API + implementation "org.kohsuke:github-api:1.306" + implementation "io.jsonwebtoken:jjwt-impl:0.11.5" + implementation "io.jsonwebtoken:jjwt-jackson:0.11.5" } configurations { diff --git a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/GitHubAppHelper.java b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/GitHubAppHelper.java new file mode 100644 index 0000000..9a56c36 --- /dev/null +++ b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/GitHubAppHelper.java @@ -0,0 +1,105 @@ +package i5.las2peer.services.apiTestingBot.util; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.kohsuke.github.GHAppInstallation; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; + +import javax.xml.bind.DatatypeConverter; +import java.io.IOException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; + +/** + * A GitHub app can be installed multiple times (e.g., within different organizations or repositories). + * To use the GitHub API for an app installation, we need an access token for this app installation. + * For requesting this access token, a JWT is needed. This JWT allows to authenticate as a GitHub app. + * The JWT needs to be signed using the app's private key (from general app settings). + * + * See https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps + */ +public class GitHubAppHelper { + + /** + * Id of the GitHub app. + */ + private int gitHubAppId; + + /** + * Private key used to sign JWTs. + */ + private Key privateKey; + + /** + * + * @param gitHubAppId Id of the GitHub app + * @param pkcs8PrivateKey Private key of GitHub app (already needs to be converted to pkcs8) + * @throws GitHubAppHelperException + */ + public GitHubAppHelper(int gitHubAppId, String pkcs8PrivateKey) throws GitHubAppHelperException { + this.gitHubAppId = gitHubAppId; + + byte[] pkcs8PrivateKeyBytes = DatatypeConverter.parseBase64Binary(pkcs8PrivateKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8PrivateKeyBytes); + try { + this.privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new GitHubAppHelperException(e.getMessage()); + } + } + + /** + * Returns a GitHub object that has access to the given repository. + * @param repositoryFullName Full name of the repository, containing both owner and repository name. + * @return GitHub object that has access to the given repository. + */ + public GitHub getGitHubInstance(String repositoryFullName) { + String ownerName = repositoryFullName.split("/")[0]; + String repoName = repositoryFullName.split("/")[1]; + + try { + // first create GitHub object using a JWT (this is needed to request an access token for an app installation) + GitHub gitHub = new GitHubBuilder().withJwtToken(generateJWT()).build(); + + // get app installation for given repository (getInstallationByRepository requires a JWT) + GHAppInstallation installation = gitHub.getApp().getInstallationByRepository(ownerName, repoName); + + // create a GitHub object with app installation token + return new GitHubBuilder().withAppInstallationToken(installation.createToken().create().getToken()).build(); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + /** + * Generates a JWT and signs it with the app's private key. + * @return JWT + */ + private String generateJWT() { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date expiration = new Date(nowMillis + 60000); + return Jwts.builder() + .setIssuedAt(now) // issue now + .setExpiration(expiration) // expiration time of JWT + .setIssuer(String.valueOf(gitHubAppId)) // app id needs to be used as issuer + .signWith(this.privateKey, SignatureAlgorithm.RS256) // sign with app's private key + .compact(); + } + + /** + * General exception that is thrown if something related to the GitHubAppHelper is not working. + */ + public class GitHubAppHelperException extends Exception { + public GitHubAppHelperException(String message) { + super(message); + } + } + +} From cb668cd599ff3ec8e88b0efc31fc98e591404a24 Mon Sep 17 00:00:00 2001 From: Philipp Dolif Date: Fri, 19 Aug 2022 11:18:58 +0200 Subject: [PATCH 2/3] Add env variables for GitHub app config #17 --- .../services/apiTestingBot/APITestingBot.java | 22 +++++++++++++++++++ docker-entrypoint.sh | 2 ++ ...ces.apiTestingBot.APITestingBot.properties | 2 ++ 3 files changed, 26 insertions(+) diff --git a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java index fd85f05..171b46b 100644 --- a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java +++ b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java @@ -22,6 +22,16 @@ public class APITestingBot extends RESTService { private String botManagerURL; private String caeBackendURL; + /** + * Id of GitHub app that the bot uses. + */ + private int gitHubAppId; + + /** + * Private key of GitHub app that the bot uses. + */ + private String gitHubAppPrivateKey; + public APITestingBot() { setFieldValues(); } @@ -90,4 +100,16 @@ private JSONObject createWebhookPayload(String message, String messenger, String public String getCaeBackendURL() { return caeBackendURL; } + + public String getBotManagerURL() { + return botManagerURL; + } + + public int getGitHubAppId() { + return gitHubAppId; + } + + public String getGitHubAppPrivateKey() { + return gitHubAppPrivateKey; + } } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 830ce33..6c337bf 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -35,6 +35,8 @@ set_in_web_config httpsPort ${HTTPS_PORT} set_in_service_config botManagerURL ${BOT_MANAGER_URL} set_in_service_config caeBackendURL ${CAE_BACKEND_URL} +set_in_service_config gitHubAppId ${GITHUB_APP_ID} +set_in_service_config gitHubAppPrivateKey ${GITHUB_APP_PRIVATE_KEY} # set defaults for optional service parameters diff --git a/etc/i5.las2peer.services.apiTestingBot.APITestingBot.properties b/etc/i5.las2peer.services.apiTestingBot.APITestingBot.properties index bdba30a..9932ec0 100644 --- a/etc/i5.las2peer.services.apiTestingBot.APITestingBot.properties +++ b/etc/i5.las2peer.services.apiTestingBot.APITestingBot.properties @@ -1,2 +1,4 @@ botManagerURL= caeBackendURL= +gitHubAppId= +gitHubAppPrivateKey= From 708130ed2b3dff33852ad05f0f1e283015b0e225 Mon Sep 17 00:00:00 2001 From: Philipp Dolif Date: Fri, 19 Aug 2022 11:28:16 +0200 Subject: [PATCH 3/3] Propose spec-based test cases in GitHub pull requests #17 --- .../services/apiTestingBot/APITestingBot.java | 4 +- .../services/apiTestingBot/RESTResources.java | 88 ++++- .../apiTestingBot/util/PRTestGenHelper.java | 321 ++++++++++++++++++ 3 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/PRTestGenHelper.java diff --git a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java index 171b46b..a2bbb7b 100644 --- a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java +++ b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/APITestingBot.java @@ -73,7 +73,7 @@ public void sendAPIDocChangesMessage(String openAPIDocOld, String openAPIDocUpda * @param webhookPayload Payload of the webhook call * @return JSONObject that can be used as the content of a monitoring message to trigger a webhook call. */ - private JSONObject createWebhook(String url, JSONObject webhookPayload) { + public static JSONObject createWebhook(String url, JSONObject webhookPayload) { JSONObject webhook = new JSONObject(); webhook.put("url", url); webhook.put("payload", webhookPayload); @@ -88,7 +88,7 @@ private JSONObject createWebhook(String url, JSONObject webhookPayload) { * @param channel Channel to which the message should be posted. * @return JSONObject representing the payload for a webhook call to the SBF that will trigger a chat message. */ - private JSONObject createWebhookPayload(String message, String messenger, String channel) { + public static JSONObject createWebhookPayload(String message, String messenger, String channel) { JSONObject webhookPayload = new JSONObject(); webhookPayload.put("event", "chat_message"); webhookPayload.put("message", message); diff --git a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/RESTResources.java b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/RESTResources.java index f86edfe..b4e9488 100644 --- a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/RESTResources.java +++ b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/RESTResources.java @@ -1,6 +1,7 @@ package i5.las2peer.services.apiTestingBot; import i5.las2peer.api.Context; +import i5.las2peer.apiTestModel.*; import i5.las2peer.services.apiTestingBot.chat.GHMessageHandler; import i5.las2peer.services.apiTestingBot.chat.Intent; import i5.las2peer.services.apiTestingBot.chat.MessageHandler; @@ -8,16 +9,17 @@ import i5.las2peer.services.apiTestingBot.context.MessengerType; import i5.las2peer.services.apiTestingBot.context.TestModelingContext; import i5.las2peer.services.apiTestingBot.context.TestModelingState; +import i5.las2peer.services.apiTestingBot.util.PRTestGenHelper; import io.swagger.annotations.Api; +import kong.unirest.Unirest; import org.json.simple.JSONObject; import org.json.simple.JSONValue; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; +import javax.ws.rs.*; import javax.ws.rs.core.Response; import java.io.Serializable; +import java.net.HttpURLConnection; import static i5.las2peer.services.apiTestingBot.context.MessengerType.*; import static i5.las2peer.services.apiTestingBot.context.TestModelingState.*; @@ -243,4 +245,84 @@ public Response modelTest(String body) { return Response.status(200).entity(res.toJSONString()).build(); } + + /** + * Gets called by SBF if user comments "@testingbot code". + * + * @param body + * @return Response message containing the generated code. + */ + @POST + @Path("/test/code") + public Response generateTestCode(String body) { + String responseMessage = ""; + + JSONObject bodyJSON = (JSONObject) JSONValue.parse(body); + String message = (String) bodyJSON.get("msg"); + String messenger = (String) bodyJSON.get("messenger"); + MessengerType messengerType = MessengerType.fromString(messenger); + String channel = (String) bodyJSON.get("channel"); + + if(message.equalsIgnoreCase("@testingbot code") && messengerType == GITHUB_PR && + PRTestGenHelper.generatedTestCases.containsKey(channel)) { + TestCase generatedTestCase = PRTestGenHelper.generatedTestCases.get(channel); + + // generate code for the test case + try { + String code = (String) Context.get().invoke("i5.las2peer.services.codeGenerationService.CodeGenerationService", + "generateTestMethod", new Serializable[]{ generatedTestCase }); + + // post generated code as a comment + responseMessage += "Here is the generated test method code:"; + responseMessage += "\n"; + responseMessage += "```java\n"; + responseMessage += code; + responseMessage += "```"; + } catch (Exception e) { + e.printStackTrace(); + } + } + + JSONObject res = new JSONObject(); + res.put("text", responseMessage); + + return Response.status(200).entity(res.toJSONString()).build(); + } + + /** + * Endpoint that receives the webhook events from the GitHub app. + * The events are redirected to the SBF (e.g., to react to issue or PR comments). + * If the event is a pull request workflow run event, it is also checked if the API testing bot can generate + * a test case for an operation that has been added or changed within the pull request. + * + * @param body Event payload + * @param eventName Name of GitHub event + * @param gitHubAppId Id of GitHub app + * @return 200 + */ + @POST + @Path("/github/webhook/{gitHubAppId}") + public Response receiveWebhookEvent(String body, @HeaderParam("X-GitHub-Event") String eventName, + @PathParam("gitHubAppId") int gitHubAppId) { + APITestingBot service = (APITestingBot) Context.get().getService(); + if(service.getGitHubAppId() != gitHubAppId) return Response.status(HttpURLConnection.HTTP_INTERNAL_ERROR).build(); + + // redirect event to SBF + this.redirectWebhookEventToSBF(gitHubAppId, eventName, body); + + JSONObject jsonBody = (JSONObject) JSONValue.parse(body); + if(PRTestGenHelper.isRelevantWorkflowEvent(eventName, jsonBody)) { + PRTestGenHelper.handleWorkflowEvent(jsonBody, service.getBotManagerURL(), service.getGitHubAppId(), + service.getGitHubAppPrivateKey()); + } + + return Response.status(HttpURLConnection.HTTP_OK).build(); + } + + private void redirectWebhookEventToSBF(int gitHubAppId, String eventName, String body) { + APITestingBot service = (APITestingBot) Context.get().getService(); + String sbfWebhookUrl = service.getBotManagerURL() + "/github/webhook/" + gitHubAppId; + Unirest.post(sbfWebhookUrl).body(body).header("X-GitHub-Event", eventName).asEmpty(); + } + } diff --git a/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/PRTestGenHelper.java b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/PRTestGenHelper.java new file mode 100644 index 0000000..9b2e6f1 --- /dev/null +++ b/api-testing-bot/src/main/java/i5/las2peer/services/apiTestingBot/util/PRTestGenHelper.java @@ -0,0 +1,321 @@ +package i5.las2peer.services.apiTestingBot.util; + +import i5.las2peer.api.Context; +import i5.las2peer.api.logging.MonitoringEvent; +import i5.las2peer.apiTestModel.*; +import i5.las2peer.services.apiTestingBot.APITestingBot; +import org.apache.commons.io.IOUtils; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.kohsuke.github.GHArtifact; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHWorkflowRun; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +public class PRTestGenHelper { + + /** + * The spec-based test case generation in GitHub pull requests expects that there exists a GitHub actions workflow + * that generates an artifact containing the OpenAPI documentation. + * + * This variable contains the name of the artifact. + */ + private static final String OPENAPI_DOC_ARTIFACT_NAME = "swagger.json"; + + /** + * The spec-based test case generation in GitHub pull requests expects that there exists a GitHub actions workflow + * that generates an artifact containing the OpenAPI documentation. + * + * This variable contains the filename of the OpenAPI document. + */ + private static final String OPENAPI_DOC_FILE_NAME = "swagger.json"; + + /** + * Stores the latest test case that has been generated for a GitHub pull request. + * The key is [OWNER]/[REPO NAME]#[PR NUMBER]. + * This map is needed, because the users can ask the bot to generate the test case code. + * Then, the bot needs to remember which test case got suggested previously. + */ + public static HashMap generatedTestCases = new HashMap<>(); + + private static String BOT_COMMAND_OVERVIEW = "---\n\n" + + "
\n" + + "Testing Bot commands\n" + + "
\n\n" + + "You can use the following commands:\n" + + "- `@testingbot code` will generate the code for the test case\n" + + "
"; + + /** + * Handles a relevant workflow event, i.e., an event that tells that a workflow run of a pull request finished + * successfully. Checks if OpenAPI docs are available for both the base branch and the latest pull request state. + * This allows to compute the changes of the OpenAPI documentation (that were introduced by the pull request) + * and to try generating a test case for the changed operations. If a test case could be generated, the bot posts a + * comment to the pull request, where the test case is presented. + * + * @param eventPayload Payload of GitHub event + * @param botManagerUrl URL of SBFManager + * @param gitHubAppId Id of GitHub app that the bot uses + * @param gitHubAppPrivateKey Private key of GitHub app that the bot uses + */ + public static void handleWorkflowEvent(JSONObject eventPayload, String botManagerUrl, int gitHubAppId, String gitHubAppPrivateKey) { + JSONObject repository = (JSONObject) eventPayload.get("repository"); + String repoFullName = (String) repository.get("full_name"); + JSONObject workflowRun = (JSONObject) eventPayload.get("workflow_run"); + long workflowRunId = (Long) workflowRun.get("id"); + + // check if workflow run of PR contains OpenAPI doc as an artifact + if(!containsSwaggerArtifact(repoFullName, workflowRunId, gitHubAppId, gitHubAppPrivateKey)) return; + + // get pull request + JSONObject pullRequest = (JSONObject) ((JSONArray) workflowRun.get("pull_requests")).get(0); + + // check if the latest workflow run on the base branch of the pull request contains OpenAPI doc as an artifact + long baseWorkflowRunId = getPRBaseWorkflowRunId(repoFullName, pullRequest, gitHubAppId, gitHubAppPrivateKey); + if(!containsSwaggerArtifact(repoFullName, baseWorkflowRunId, gitHubAppId, gitHubAppPrivateKey)) return; + + // OpenAPI doc is given for both base branch and current PR state => load both files + String swaggerJson = getSwaggerJsonContent(getSwaggerJsonArtifact(repoFullName, workflowRunId, gitHubAppId, gitHubAppPrivateKey)); + String swaggerJsonBase = getSwaggerJsonContent(getSwaggerJsonArtifact(repoFullName, baseWorkflowRunId, gitHubAppId, gitHubAppPrivateKey)); + if(swaggerJson == null || swaggerJsonBase == null) return; + + String testGenServiceResult = callTestGenService(swaggerJson, swaggerJsonBase); + if(testGenServiceResult == null) return; + + JSONObject resultJSON = (JSONObject) JSONValue.parse(testGenServiceResult); + if(!resultJSON.containsKey("testCase")) return; + + JSONObject testCaseJSON = (JSONObject) resultJSON.get("testCase"); + String testCaseDescription = (String) resultJSON.get("description"); + TestCase testCase = new TestCase(testCaseJSON); + + String channel = repoFullName + "#" + pullRequest.get("number"); + postPRComment(channel, getTestCasePresentationComment(testCase, testCaseDescription), botManagerUrl); + + generatedTestCases.put(channel, testCase); + } + + /** + * Workflow run events are only relevant if: + * - run is completed + * - run event is a pull request + * - run was successful + * + * @param eventName Name of GitHub event + * @param eventPayload Payload of GitHub event + * @return Whether the given event is a relevant workflow event. + */ + public static boolean isRelevantWorkflowEvent(String eventName, JSONObject eventPayload) { + if(eventName.equals("workflow_run")) { + String action = (String) eventPayload.get("action"); + if (action.equals("completed")) { + // check if workflow run belongs to a pull request and was successful + JSONObject workflowRun = (JSONObject) eventPayload.get("workflow_run"); + String conclusion = (String) workflowRun.get("conclusion"); + String event = (String) workflowRun.get("event"); + if (event.equals("pull_request") && conclusion.equals("success")) { + return true; + } + } + } + return false; + } + + /** + * Calls the method "openAPIDiffToTest" of the APITestGenService. + * + * @param swaggerJson Latest OpenAPI doc of PR + * @param swaggerJsonBase OpenAPI doc of PR base branch + * @return Test case and description, if a test case could be generated. + */ + private static String callTestGenService(String swaggerJson, String swaggerJsonBase) { + try { + return (String) Context.get().invoke("i5.las2peer.services.apiTestGenService.APITestGenService", + "openAPIDiffToTest", new Serializable[]{ swaggerJsonBase, swaggerJson }); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * Posts a comment to a GitHub pull request. + * + * @param channel [OWNER]/[REPO NAME]#[PR NUMBER] + * @param comment Comment to post + * @param botManagerUrl URL of SBFManager + */ + private static void postPRComment(String channel, String comment, String botManagerUrl) { + String webhookUrl = botManagerUrl + "/bots/" + "CAEBot" + "/webhook"; + String messenger = "GitHub Pull Requests"; + JSONObject webhook = APITestingBot.createWebhook(webhookUrl, APITestingBot.createWebhookPayload(comment, messenger, channel)); + JSONObject monitoringMessage = new JSONObject(); + monitoringMessage.put("webhook", webhook); + + Context.get().monitorEvent(MonitoringEvent.SERVICE_CUSTOM_MESSAGE_1, monitoringMessage.toJSONString()); + } + + /** + * Creates a pull request comment text presenting the given test case. + * + * @param testCase Test case + * @param testCaseDescription Explanation why test case has been generated + * @return Pull request comment text presenting the given test case. + */ + private static String getTestCasePresentationComment(TestCase testCase, String testCaseDescription) { + // explain why test case has been generated and give test case name + String message = "I have generated the following test case:\n"; + message += testCaseDescription; + message += "\n\n"; + message += "" + testCase.getName() + "\n---\n"; + + // show request method and path + TestRequest request = testCase.getRequests().get(0); + String url = getRequestUrlWithPathParamValues(request); + message += "**Method & Path:** `" + request.getType() + "` `" + url + "`\n"; + + // request auth info + String auth = request.getAgent() == -1 ? "None" : "User Agent"; + message += "**Authorization:** " + auth + "\n"; + + // request body (if exists) + if(request.getBody() != null && !request.getBody().isEmpty()) { + message += "**Body:**\n" + "```json" + request.getBody() + "```\n"; + } + + // list assertions + message += "**Assertions:**\n"; + for(RequestAssertion assertion : request.getAssertions()) { + message += "- "; + if(assertion instanceof StatusCodeAssertion) { + message += "Expected status code: " + ((StatusCodeAssertion) assertion).getStatusCodeValue(); + } else { + message += ((BodyAssertion) assertion).getOperator().toString(); + } + message += "\n"; + } + + // append overview of available bot commands + message += BOT_COMMAND_OVERVIEW; + + return message; + } + + /** + * Returns the request url of the given test request where the path parameters are replaced with their values. + * + * @param request TestRequest + * @return Request url of the given test request where the path parameters are replaced with their values. + */ + private static String getRequestUrlWithPathParamValues(TestRequest request) { + String url = request.getUrl(); + JSONObject pathParams = request.getPathParams(); + for(Object key : pathParams.keySet()) { + String paramValue = String.valueOf(pathParams.get(key)); + if(paramValue.isEmpty()) paramValue = ""; + url = url.replace("{" + key + "}", paramValue); + } + return url; + } + + /** + * Checks if the workflow run with the given id contains an OpenAPI doc artifact. + * + * @param repoFullName Full name of repository + * @param workflowRunId Id of GitHub actions workflow run + * @param gitHubAppId Id of GitHub app that the bot uses + * @param gitHubAppPrivateKey Private key of GitHub app that the bot uses + * @return Whether the workflow run with the given id contains an OpenAPI doc artifact. + */ + private static boolean containsSwaggerArtifact(String repoFullName, long workflowRunId, int gitHubAppId, String gitHubAppPrivateKey) { + return getSwaggerJsonArtifact(repoFullName, workflowRunId, gitHubAppId, gitHubAppPrivateKey) != null; + } + + /** + * Returns the id of the workflow run that belongs to the base branch of the given pull request. + * + * @param repoFullName Full name of repository + * @param pullRequest Pull request info + * @param gitHubAppId Id of GitHub app that the bot uses + * @param gitHubAppPrivateKey Private key of GitHub app that the bot uses + * @return Id of the workflow run that belongs to the base branch of the given pull request. + */ + private static long getPRBaseWorkflowRunId(String repoFullName, JSONObject pullRequest, int gitHubAppId, String gitHubAppPrivateKey) { + JSONObject base = (JSONObject) pullRequest.get("base"); + String baseBranchName = (String) base.get("ref"); + String baseSHA = (String) base.get("sha"); + + try { + GitHubAppHelper helper = new GitHubAppHelper(gitHubAppId, gitHubAppPrivateKey); + GHRepository gitHubRepo = helper.getGitHubInstance(repoFullName).getRepository(repoFullName); + List runs = gitHubRepo.queryWorkflowRuns().branch(baseBranchName).list().toList(); + for(GHWorkflowRun run : runs) { + if(run.getHeadSha().equals(baseSHA)) { + return run.getId(); + } + } + } catch (GitHubAppHelper.GitHubAppHelperException | IOException e) { + e.printStackTrace(); + } + return -1; + } + + /** + * Searches for the artifact of the given workflow run that contains the OpenAPI doc. + * + * @param repoFullName Full name of repository + * @param workflowRunId Id of GitHub actions workflow run + * @param gitHubAppId Id of GitHub app that the bot uses + * @param gitHubAppPrivateKey Private key of GitHub app that the bot uses + * @return Artifact of the given workflow run that contains the OpenAPI doc. + */ + private static GHArtifact getSwaggerJsonArtifact(String repoFullName, long workflowRunId, int gitHubAppId, String gitHubAppPrivateKey) { + try { + GitHubAppHelper helper = new GitHubAppHelper(gitHubAppId, gitHubAppPrivateKey); + GHRepository gitHubRepo = helper.getGitHubInstance(repoFullName).getRepository(repoFullName); + List artifacts = gitHubRepo.getWorkflowRun(workflowRunId).listArtifacts().toList(); + for(GHArtifact artifact : artifacts) { + if(artifact.getName().equals(OPENAPI_DOC_ARTIFACT_NAME)) { + return artifact; + } + } + } catch (GitHubAppHelper.GitHubAppHelperException | IOException e) { + e.printStackTrace(); + } + return null; + } + + /** + * Downloads the given artifact from GitHub, extracts it, and searches for the OpenAPI doc file. + * + * @param artifact Artifact from GitHub workflow run + * @return Content of OpenAPI doc file + */ + private static String getSwaggerJsonContent(GHArtifact artifact) { + try { + return artifact.download(is -> { + ZipInputStream zipInputStream = new ZipInputStream(is); + // search for file + ZipEntry nextEntry = zipInputStream.getNextEntry(); + while (nextEntry != null) { + if (nextEntry.getName().equals(OPENAPI_DOC_FILE_NAME)) + break; + nextEntry = zipInputStream.getNextEntry(); + } + // return file content as String + return IOUtils.toString(zipInputStream, StandardCharsets.UTF_8); + }); + } catch (IOException e) { + return null; + } + } + +}