diff --git a/pom.xml b/pom.xml index 2428b7d4..b864d685 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,22 @@ 3.24.2 test + + com.github.tomakehurst + wiremock-jre8-standalone + 2.35.0 + test + + + org.jenkins-ci.plugins.workflow + workflow-durable-task-step + test + + + org.jenkins-ci.plugins + pipeline-stage-step + test + diff --git a/src/test/java/io/jenkins/plugins/checks/github/GitHubChecksPublisherITest.java b/src/test/java/io/jenkins/plugins/checks/github/GitHubChecksPublisherITest.java new file mode 100644 index 00000000..b09a00c8 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/checks/github/GitHubChecksPublisherITest.java @@ -0,0 +1,444 @@ +package io.jenkins.plugins.checks.github; + +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import hudson.model.Action; +import hudson.model.Result; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; +import java.util.function.Function; +import java.util.logging.Level; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.mockito.MockedStatic; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.introspect.VisibilityChecker; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import org.kohsuke.github.GHCheckRun; +import org.kohsuke.github.GHCheckRunBuilder; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.jenkinsci.plugins.displayurlapi.ClassicDisplayURLProvider; +import org.jenkinsci.plugins.github_branch_source.Connector; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.jenkinsci.plugins.github_branch_source.GitHubSCMSource; +import org.jenkinsci.plugins.github_branch_source.PullRequestSCMRevision; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import hudson.model.FreeStyleProject; +import hudson.model.Job; +import hudson.model.Queue; +import hudson.model.Run; +import hudson.util.Secret; +import jenkins.model.ParameterizedJobMixIn; +import jenkins.scm.api.SCMHead; + +import io.jenkins.plugins.checks.api.ChecksAction; +import io.jenkins.plugins.checks.api.ChecksAnnotation.ChecksAnnotationBuilder; +import io.jenkins.plugins.checks.api.ChecksAnnotation.ChecksAnnotationLevel; +import io.jenkins.plugins.checks.api.ChecksConclusion; +import io.jenkins.plugins.checks.api.ChecksDetails; +import io.jenkins.plugins.checks.api.ChecksDetails.ChecksDetailsBuilder; +import io.jenkins.plugins.checks.api.ChecksImage; +import io.jenkins.plugins.checks.api.ChecksOutput.ChecksOutputBuilder; +import io.jenkins.plugins.checks.api.ChecksStatus; +import io.jenkins.plugins.util.PluginLogger; + +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** + * Tests if the {@link GitHubChecksPublisher} actually sends out the requests to GitHub in order to publish the check + * runs. + */ +@RunWith(Parameterized.class) +@SuppressWarnings({"PMD.ExcessiveImports", "checkstyle:ClassDataAbstractionCoupling", "rawtypes", "checkstyle:ClassFanOutComplexity", "checkstyle:JavaNCSS"}) +public class GitHubChecksPublisherITest { + + private static final String TEST_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDWV2v0jCfzbyTi\n" + + "r3mIufQSvXQj02e0Hbia0BOjYluZ2ife/RMs8mrzxAfWUtyrWsi+50OvbxXx+mk1\n" + + "drn+aR0z0YJ7gqymvn2zWUDv+99eWSb9yeKT3cZU7EpcwtL8APPLzSycoPeylkf8\n" + + "jtWopdglWO7AXnA+OIiW/luxgxzjUL6lrzye/9l67qQksy6F42+X5jKTZYx2e3vd\n" + + "I/NZgCGd/2h61RAHJH/2QwujYva2kc5pvm0JmwHKWqEWu+i6lcGXeL/C3zkyh8To\n" + + "ROFNMz/12+mUbqye1dAg19JcJtmM8ymHsmfFc9CGmXQyuAuhU4zPssA/2i0rPWl+\n" + + "xthlEA6TAgMBAAECggEASVrf8nCpF5H5IK+HO3jQhD1cawpl2mm1jR4bKnZ1/QCB\n" + + "Vrpr/pz0Z3q2Z+4x4V8Phu4k5vxwmUDnEsoQO3aD7QEN0/FT3zkgUeoA5GDiACso\n" + + "wgB+z7Y9s0Cu7nIqvN4ikaQlWXFpdDAkcNX9X1tqztVR2Ho5lcHJVUu129mQYGbY\n" + + "ivdmSIjLn9oqFhqOpdYtLSoiNtoJmhyFTQj0G+DTumS9G556sBRuZI7qwAKrd6+D\n" + + "GPvbgVC7mcGogDgUyIAMLj9Q+EfjlX+gfWtqabF9v5Wxp0u1vdC+mdmL/IgbqGTW\n" + + "DYEQAS1gkkLYXQZXBp7vREU5Oq/W2/okX4FaRNzW2QKBgQDurWDX3Jh+3Q+raQy4\n" + + "qyN3WZ4XUPzmVoQ11+GY/SkcFXK3r6xD+FZtjUv8yugarnjdYPeG3SUJKhEhVnl8\n" + + "Ja2CLruZB6sbfsQ2lA9vKR87upWV4DJftuAdFnWVVMD6ti1KCTHIDiUl0YAxWF6A\n" + + "EGKDrzQIVdTtBn6+Hrhn59AYtQKBgQDl5eKQiyA1wQ/nO4u+VskcrBcaTQJjNysz\n" + + "mo9k+jpJQqVrJ2kNopbkaZyz74IXI73rQ8CmctAPrSiucj1SeMBWWPWXDC+hxzjV\n" + + "NURdmEh7D0fpKAknn2WPrIDrqLgsVTiCEX/XicX3eCTuKf+mSUwv//6MFhDIntC4\n" + + "2PdCtMD/JwKBgBFEH55eCfYbfdezmMT/NGic5g/fvvvWxGe0v1A2+DNc5did78NX\n" + + "AsGYGCgocZQEjR/OtPlfpB8+mNClldJCU4P4Z3/RizJJAF7GZTtwaR8EB3A5MMu1\n" + + "yg6woj70S6WXaj1R3vUO+Ob8ed6X+vYeuVG3afc0ZlvjPWX5iPOTVH2FAoGAYqnc\n" + + "KChtNGSczKITgSaBvRpl99Wg9q+QjN8CN1XkedhuYaRSQ5XJqFFi/R4G+KNQOI2l\n" + + "Okn/3Rp1YRiKFMDZ2rTnAWIrdwSm8Wmg44IdaSLPu9KAy05vKc/grEKGeBBC5h9Y\n" + + "fEoWefRH9SZ1HwpJ9jepKLm3jkIKVapXw3sLcPUCgYBdbDosTe2LtF2buaNsBMQw\n" + + "H3c9fHllrDSy2Twr12ShSc5xMIqTWtiTAvEcMZYP4BX9uSPUWwaB7wMBx2CCMsXR\n" + + "sZLRujRKV9s5qSmUOXHQWIcmsEvjyxiNtVNhi3rXeMMgYISleUH4ife4evulPHzD\n" + + "gppAplykAFg49TGEqr7ihQ==\n" + + "-----END PRIVATE KEY-----"; + + /** + * Provides parameters for tests. + * @return A list of methods used to create GitHubChecksContexts, with which each test should be run. + */ + @Parameterized.Parameters(name = "{0}") + public static Collection contextBuilders() { + return Arrays.asList(new Object[][]{ + {"Freestyle (run)", (Function) GitHubChecksPublisherITest::createGitHubChecksContextWithGitHubSCMFreestyle, false}, + {"Freestyle (job)", (Function) GitHubChecksPublisherITest::createGitHubChecksContextWithGitHubSCMFreestyle, true}, + {"Pipeline (run)", (Function) GitHubChecksPublisherITest::createGitHubChecksContextWithGitHubSCMFromPipeline, false}, + {"Pipeline (job)", (Function) GitHubChecksPublisherITest::createGitHubChecksContextWithGitHubSCMFromPipeline, true} + }); + } + + /** + * Human readable name of the context builder - used only for test name formatting. + */ + @SuppressWarnings("checkstyle:VisibilityModifier") + @Parameterized.Parameter(0) + public String contextBuilderName; + + /** + * Reference to method used to create GitHubChecksContext with either a pipeline or freestyle job. + */ + @SuppressWarnings("checkstyle:VisibilityModifier") + @Parameterized.Parameter(1) + public Function contextBuilder; + + /** + * Create GitHubChecksContext from the job instead of the run. + */ + @SuppressWarnings("checkstyle:VisibilityModifier") + @Parameterized.Parameter(2) + public boolean fromJob; + + /** + * Rule for the log system. + */ + @Rule + public LoggerRule loggerRule = new LoggerRule(); + + @Rule + public JenkinsRule j = new JenkinsRule(); + + /** + * A rule which provides a mock server. + */ + @Rule + public WireMockRule wireMockRule = new WireMockRule( + WireMockConfiguration.options().dynamicPort()); + + /** + * Checks should be published to GitHub correctly when GitHub SCM is found and parameters are correctly set. + */ + @Test + public void shouldPublishGitHubCheckRunCorrectly() { + ChecksDetails details = new ChecksDetailsBuilder() + .withName("Jenkins") + .withStatus(ChecksStatus.COMPLETED) + .withDetailsURL("https://ci.jenkins.io") + .withStartedAt(LocalDateTime.ofEpochSecond(999_999, 0, ZoneOffset.UTC)) + .withCompletedAt(LocalDateTime.ofEpochSecond(999_999, 0, ZoneOffset.UTC)) + .withConclusion(ChecksConclusion.SUCCESS) + .withOutput(new ChecksOutputBuilder() + .withTitle("Jenkins Check") + .withSummary("# A Successful Build") + .withText("## 0 Failures") + .withAnnotations(Arrays.asList( + new ChecksAnnotationBuilder() + .withPath("Jenkinsfile") + .withLine(1) + .withAnnotationLevel(ChecksAnnotationLevel.NOTICE) + .withMessage("say hello to Jenkins") + .withStartColumn(0) + .withEndColumn(20) + .withTitle("Hello Jenkins") + .withRawDetails("a simple echo command") + .build(), + new ChecksAnnotationBuilder() + .withPath("Jenkinsfile") + .withLine(2) + .withAnnotationLevel(ChecksAnnotationLevel.WARNING) + .withMessage("say hello to GitHub Checks API") + .withStartColumn(0) + .withEndColumn(30) + .withTitle("Hello GitHub Checks API") + .withRawDetails("a simple echo command") + .build())) + .withImages(Collections.singletonList( + new ChecksImage("Jenkins", + "https://ci.jenkins.io/static/cd5757a8/images/jenkins-header-logo-v2.svg", + "Jenkins Symbol"))) + .build()) + .withActions(Collections.singletonList( + new ChecksAction("re-run", "re-run Jenkins build", "#0"))) + .build(); + + new GitHubChecksPublisher(contextBuilder.apply(this), + new PluginLogger(j.createTaskListener().getLogger(), "GitHub Checks"), + wireMockRule.baseUrl()) + .publish(details); + } + + /** + * If exception happens when publishing checks, it should output all parameters of the check to the system log. + */ + @Issue("issue-20") + @Test + public void shouldLogChecksParametersIfExceptionHappensWhenPublishChecks() { + loggerRule.record(GitHubChecksPublisher.class.getName(), Level.WARNING).capture(1); + + ChecksDetails details = new ChecksDetailsBuilder() + .withName("Jenkins") + .withStatus(ChecksStatus.COMPLETED) + .withConclusion(ChecksConclusion.SUCCESS) + .withOutput(new ChecksOutputBuilder() + .withTitle("Jenkins Check") + .withSummary("# A Successful Build") + .withAnnotations(Collections.singletonList( + new ChecksAnnotationBuilder() + .withPath("Jenkinsfile") + .withStartLine(1) + .withEndLine(2) + .withStartColumn(0) + .withEndColumn(20) + .withAnnotationLevel(ChecksAnnotationLevel.WARNING) + .withMessage("say hello to Jenkins") + .build())) + .build()) + .build(); + + new GitHubChecksPublisher(contextBuilder.apply(this), + new PluginLogger(j.createTaskListener().getLogger(), "GitHub Checks"), + wireMockRule.baseUrl()) + .publish(details); + + assertThat(loggerRule.getRecords().size()).isEqualTo(1); + assertThat(loggerRule.getMessages().get(0)) + .contains("Failed Publishing GitHub checks: ") + .contains("name='Jenkins'") + .contains("status=COMPLETED") + .contains("conclusion=SUCCESS") + .contains("title='Jenkins Check'") + .contains("summary='# A Successful Build'") + .contains("path='Jenkinsfile'") + .contains("startLine=1") + .contains("endLine=2") + .contains("startColumn=0") + .contains("endColumn=20") + .contains("annotationLevel=WARNING") + .contains("message='say hello to Jenkins'"); + } + + /** + * We can't mock the id field on {@link org.kohsuke.github.GHObject}s thanks to {@link com.infradna.tool.bridge_method_injector.WithBridgeMethods}. + * So, create a stub GHCheckRun with the id we want. + * @param id id of check run to spoof + * @return Stubbed {@link GHCheckRun} with only the id of {@link GHCheckRun} set + */ + private GHCheckRun createStubCheckRun(final long id) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(new VisibilityChecker.Std(NONE, NONE, NONE, NONE, ANY)); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true); + mapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); + + InjectableValues.Std std = new InjectableValues.Std(); + std.addValue("org.kohsuke.github.connector.GitHubConnectorResponse", null); + std.addValue("org.kohsuke.github.GitHub", null); + ObjectReader reader = mapper.reader(std).forType(GHCheckRun.class); + + return reader.readValue(String.format("{\"id\": %d}", id)); + } + + /** + * Test that publishing a second check with the same name will update rather than overwrite the existing check. + */ + @Test + @SuppressFBWarnings(value = "RCN", justification = "False positive of SpotBugs") + public void testChecksPublisherUpdatesCorrectly() throws Exception { + GitHub gitHub = mock(GitHub.class); + GHRepository repository = mock(GHRepository.class); + when(gitHub.getRepository(anyString())).thenReturn(repository); + + long checksId1 = 1000; + long checksId2 = 2000; + + String checksName1 = "Test Updating"; + String checksName2 = "Different Tests"; + + GHCheckRunBuilder createBuilder1 = mock(GHCheckRunBuilder.class, RETURNS_SELF); + GHCheckRunBuilder createBuilder2 = mock(GHCheckRunBuilder.class, RETURNS_SELF); + GHCheckRunBuilder updateBuilder1 = mock(GHCheckRunBuilder.class, RETURNS_SELF); + + GHCheckRun createResult1 = createStubCheckRun(checksId1); + GHCheckRun createResult2 = createStubCheckRun(checksId2); + + doReturn(createResult1).when(createBuilder1).create(); + doReturn(createResult2).when(createBuilder2).create(); + doReturn(createResult1).when(updateBuilder1).create(); + + when(repository.createCheckRun(eq(checksName1), anyString())).thenReturn(createBuilder1); + when(repository.createCheckRun(eq(checksName2), anyString())).thenReturn(createBuilder2); + when(repository.updateCheckRun(checksId1)).thenReturn(updateBuilder1); + + try (MockedStatic connector = mockStatic(Connector.class)) { + connector.when(() -> Connector.connect(anyString(), any(GitHubAppCredentials.class))).thenReturn(gitHub); + + GitHubChecksContext context = contextBuilder.apply(this); + + ChecksDetails details1 = new ChecksDetailsBuilder() + .withName(checksName1) + .withStatus(ChecksStatus.IN_PROGRESS) + .build(); + + GitHubChecksPublisher publisher = new GitHubChecksPublisher(context, + new PluginLogger(j.createTaskListener().getLogger(), "GitHub Checks"), + "https://github.example.com/" + ); + + assertThat(context.getId(checksName1)).isNotPresent(); + assertThat(context.getId(checksName2)).isNotPresent(); + + publisher.publish(details1); + + verify(createBuilder1, times(1)).create(); + verify(createBuilder2, never()).create(); + verify(updateBuilder1, never()).create(); + + if (fromJob) { + assertThat(context.getId(checksName1)).isNotPresent(); + } + else { + assertThat(context.getId(checksName1)).isPresent().get().isEqualTo(checksId1); + } + assertThat(context.getId(checksName2)).isNotPresent(); + + ChecksDetails details2 = new ChecksDetailsBuilder() + .withName(checksName2) + .withStatus(ChecksStatus.COMPLETED) + .withConclusion(ChecksConclusion.SUCCESS) + .build(); + + publisher.publish(details2); + + verify(createBuilder1, times(1)).create(); + verify(createBuilder2, times(1)).create(); + verify(updateBuilder1, never()).create(); + + if (fromJob) { + assertThat(context.getId(checksName1)).isNotPresent(); + assertThat(context.getId(checksName1)).isNotPresent(); + } + else { + assertThat(context.getId(checksName1)).isPresent().get().isEqualTo(checksId1); + assertThat(context.getId(checksName2)).isPresent().get().isEqualTo(checksId2); + } + + ChecksDetails updateDetails1 = new ChecksDetailsBuilder() + .withName(checksName1) + .withStatus(ChecksStatus.COMPLETED) + .withConclusion(ChecksConclusion.FAILURE) + .build(); + + publisher.publish(updateDetails1); + + verify(createBuilder1, times(fromJob ? 2 : 1)).create(); + verify(createBuilder2, times(1)).create(); + verify(updateBuilder1, times(fromJob ? 0 : 1)).create(); + + if (fromJob) { + assertThat(context.getId(checksName1)).isNotPresent(); + assertThat(context.getId(checksName1)).isNotPresent(); + } + else { + assertThat(context.getId(checksName1)).isPresent().get().isEqualTo(checksId1); + assertThat(context.getId(checksName2)).isPresent().get().isEqualTo(checksId2); + } + } + } + + private GitHubChecksContext createGitHubChecksContextWithGitHubSCMFreestyle() { + try { + FreeStyleProject job = j.createFreeStyleProject(); + return createGitHubChecksContextWithGitHubSCM(job); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private GitHubChecksContext createGitHubChecksContextWithGitHubSCMFromPipeline() { + try { + WorkflowJob job = j.createProject(WorkflowJob.class); + job.setDefinition(new CpsFlowDefinition("node {}", true)); + return createGitHubChecksContextWithGitHubSCM(job); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private Run buildSuccessfully(ParameterizedJobMixIn.ParameterizedJob job) throws Exception { + return j.assertBuildStatus(Result.SUCCESS, job.scheduleBuild2(0, new Action[0])); + } + + private & Queue.Executable, J extends Job & ParameterizedJobMixIn.ParameterizedJob> + GitHubChecksContext createGitHubChecksContextWithGitHubSCM(final J job) throws Exception { + Run run = buildSuccessfully(job); + + SCMFacade scmFacade = mock(SCMFacade.class); + GitHubSCMSource source = mock(GitHubSCMSource.class); + SCMHead head = mock(SCMHead.class); + PullRequestSCMRevision revision = mock(PullRequestSCMRevision.class); + ClassicDisplayURLProvider urlProvider = mock(ClassicDisplayURLProvider.class); + + when(source.getCredentialsId()).thenReturn("1"); + when(source.getRepoOwner()).thenReturn("XiongKezhi"); + when(source.getRepository()).thenReturn("Sandbox"); + + GitHubAppCredentials gitHubAppCredentials = new GitHubAppCredentials(CredentialsScope.GLOBAL, + "cred-id", null, "app-id", Secret.fromString(TEST_PRIVATE_KEY)); + + when(scmFacade.findGitHubSCMSource(job)).thenReturn(Optional.of(source)); + when(scmFacade.findGitHubAppCredentials(job, "1")).thenReturn(Optional.of(gitHubAppCredentials)); + when(scmFacade.findHead(job)).thenReturn(Optional.of(head)); + when(scmFacade.findRevision(source, run)).thenReturn(Optional.of(revision)); + when(scmFacade.findRevision(source, head)).thenReturn(Optional.of(revision)); + when(scmFacade.findHash(revision)).thenReturn(Optional.of("18c8e2fd86e7aa3748e279c14a00dc3f0b963e7f")); + + when(urlProvider.getRunURL(run)).thenReturn("https://ci.jenkins.io"); + when(urlProvider.getJobURL(job)).thenReturn("https://ci.jenkins.io"); + + if (fromJob) { + return GitHubSCMSourceChecksContext.fromJob(job, urlProvider.getJobURL(job), scmFacade); + } + return GitHubSCMSourceChecksContext.fromRun(run, urlProvider.getRunURL(run), scmFacade); + } +} diff --git a/src/test/java/io/jenkins/plugins/checks/github/GitSCMChecksContextITest.java b/src/test/java/io/jenkins/plugins/checks/github/GitSCMChecksContextITest.java new file mode 100644 index 00000000..858d590a --- /dev/null +++ b/src/test/java/io/jenkins/plugins/checks/github/GitSCMChecksContextITest.java @@ -0,0 +1,88 @@ +package io.jenkins.plugins.checks.github; + +import hudson.model.Action; +import hudson.model.Result; +import java.util.Collections; + +import jenkins.model.ParameterizedJobMixIn; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.junit.Rule; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.*; + +import hudson.model.FreeStyleProject; +import hudson.model.Run; +import hudson.plugins.git.BranchSpec; +import hudson.plugins.git.GitSCM; +import org.jvnet.hudson.test.JenkinsRule; + +/** + * Integration tests for {@link GitSCMChecksContext}. + */ +public class GitSCMChecksContextITest { + private static final String EXISTING_HASH = "4ecc8623b06d99d5f029b66927438554fdd6a467"; + private static final String HTTP_URL = "https://github.com/jenkinsci/github-checks-plugin.git"; + private static final String CREDENTIALS_ID = "credentials"; + private static final String URL_NAME = "url"; + + @Rule + public JenkinsRule j = new JenkinsRule(); + + /** + * Creates a FreeStyle job that uses {@link hudson.plugins.git.GitSCM} and runs a successful build. + * Then this build is used to create a new {@link GitSCMChecksContext}. So the build actually is not publishing + * the checks we just ensure that we can create the context with the successful build (otherwise we would need + * Wiremock to handle the requests to GitHub). + */ + @Test + public void shouldRetrieveContextFromFreeStyleBuild() throws Exception { + FreeStyleProject job = j.createFreeStyleProject(); + + BranchSpec branchSpec = new BranchSpec(EXISTING_HASH); + GitSCM scm = new GitSCM(GitSCM.createRepoList(HTTP_URL, CREDENTIALS_ID), + Collections.singletonList(branchSpec), false, Collections.emptyList(), + null, null, Collections.emptyList()); + job.setScm(scm); + + Run run = buildSuccessfully(job); + + GitSCMChecksContext gitSCMChecksContext = new GitSCMChecksContext(run, URL_NAME); + + assertThat(gitSCMChecksContext.getRepository()).isEqualTo("jenkinsci/github-checks-plugin"); + assertThat(gitSCMChecksContext.getHeadSha()).isEqualTo(EXISTING_HASH); + assertThat(gitSCMChecksContext.getCredentialsId()).isEqualTo(CREDENTIALS_ID); + } + + private Run buildSuccessfully(ParameterizedJobMixIn.ParameterizedJob job) throws Exception { + return j.assertBuildStatus(Result.SUCCESS, job.scheduleBuild2(0, new Action[0])); + } + + /** + * Creates a pipeline that uses {@link hudson.plugins.git.GitSCM} and runs a successful build. + * Then this build is used to create a new {@link GitSCMChecksContext}. + */ + @Test + public void shouldRetrieveContextFromPipeline() throws Exception { + WorkflowJob job = j.createProject(WorkflowJob.class); + + job.setDefinition(new CpsFlowDefinition("node {\n" + + " stage ('Checkout') {\n" + + " checkout scm: ([\n" + + " $class: 'GitSCM',\n" + + " userRemoteConfigs: [[credentialsId: '" + CREDENTIALS_ID + "', url: '" + HTTP_URL + "']],\n" + + " branches: [[name: '" + EXISTING_HASH + "']]\n" + + " ])" + + " }\n" + + "}\n", true)); + + Run run = buildSuccessfully(job); + + GitSCMChecksContext gitSCMChecksContext = new GitSCMChecksContext(run, URL_NAME); + + assertThat(gitSCMChecksContext.getRepository()).isEqualTo("jenkinsci/github-checks-plugin"); + assertThat(gitSCMChecksContext.getCredentialsId()).isEqualTo(CREDENTIALS_ID); + assertThat(gitSCMChecksContext.getHeadSha()).isEqualTo(EXISTING_HASH); + } +} diff --git a/src/test/java/io/jenkins/plugins/checks/github/config/GitHubChecksConfigITest.java b/src/test/java/io/jenkins/plugins/checks/github/config/GitHubChecksConfigITest.java new file mode 100644 index 00000000..82b249c1 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/checks/github/config/GitHubChecksConfigITest.java @@ -0,0 +1,33 @@ +package io.jenkins.plugins.checks.github.config; + +import hudson.util.StreamTaskListener; +import io.jenkins.plugins.checks.github.GitHubChecksPublisherFactory; +import java.io.IOException; +import org.junit.Rule; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for {@link GitHubChecksConfig}. + */ +public class GitHubChecksConfigITest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + /** + * When a job has not {@link org.jenkinsci.plugins.github_branch_source.GitHubSCMSource} or + * {@link hudson.plugins.git.GitSCM}, the default config should be used and no verbose log should be output. + */ + @Test + public void shouldUseDefaultConfigWhenNoSCM() throws IOException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + GitHubChecksPublisherFactory.fromJob(j.createFreeStyleProject(), new StreamTaskListener(os)); + + assertThat(os.toString()).doesNotContain("Causes for no suitable publisher found: "); + } +} diff --git a/src/test/java/io/jenkins/plugins/checks/github/status/GitHubSCMSourceStatusChecksTraitTest.java b/src/test/java/io/jenkins/plugins/checks/github/status/GitHubSCMSourceStatusChecksTraitTest.java new file mode 100644 index 00000000..4cd2721a --- /dev/null +++ b/src/test/java/io/jenkins/plugins/checks/github/status/GitHubSCMSourceStatusChecksTraitTest.java @@ -0,0 +1,30 @@ +package io.jenkins.plugins.checks.github.status; + +import jenkins.scm.api.SCMHeadObserver; +import jenkins.scm.api.SCMSourceCriteria; +import org.jenkinsci.plugins.github_branch_source.GitHubSCMSourceContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class GitHubSCMSourceStatusChecksTraitTest { + @Test + void shouldOnlyApplyTraitConfigurationsToGitHubBranchSourceNotificationsWhenItsNotDisabled() { + GitHubSCMSourceContext context = new GitHubSCMSourceContext(mock(SCMSourceCriteria.class), + mock(SCMHeadObserver.class)); + GitHubSCMSourceStatusChecksTrait trait = new GitHubSCMSourceStatusChecksTrait(); + + // disable notifications, the trait configuration should be ignored + context.withNotificationsDisabled(true); + trait.setSkipNotifications(false); + trait.decorateContext(context); + assertThat(context.notificationsDisabled()).isTrue(); + + // enable notifications, the trait configuration should be applied + context.withNotificationsDisabled(false); + trait.setSkipNotifications(true); + trait.decorateContext(context); + assertThat(context.notificationsDisabled()).isTrue(); + } +} diff --git a/src/test/resources/__files/create-access-token.json b/src/test/resources/__files/create-access-token.json new file mode 100644 index 00000000..e81e31d1 --- /dev/null +++ b/src/test/resources/__files/create-access-token.json @@ -0,0 +1,107 @@ +{ + "token": "bogus", + "expires_at": "2019-08-10T05:54:58Z", + "permissions": { + "checks": "write", + "pull_requests": "write", + "contents": "read", + "metadata": "read" + }, + "repository_selection": "selected", + "repositories": [ + { + "id": 111111111, + "node_id": "asdfasdf", + "name": "bogus", + "full_name": "bogus/bogus", + "private": true, + "owner": { + "login": "bogus", + "id": 11111111, + "node_id": "asdfasdf", + "avatar_url": "https://avatars2.githubusercontent.com/u/11111111?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/bogus", + "html_url": "https://github.com/bogus", + "followers_url": "https://api.github.com/users/bogus/followers", + "following_url": "https://api.github.com/users/bogus/following{/other_user}", + "gists_url": "https://api.github.com/users/bogus/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bogus/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bogus/subscriptions", + "organizations_url": "https://api.github.com/users/bogus/orgs", + "repos_url": "https://api.github.com/users/bogus/repos", + "events_url": "https://api.github.com/users/bogus/events{/privacy}", + "received_events_url": "https://api.github.com/users/bogus/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/bogus/bogus", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/bogus/bogus", + "forks_url": "https://api.github.com/repos/bogus/bogus/forks", + "keys_url": "https://api.github.com/repos/bogus/bogus/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/bogus/bogus/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/bogus/bogus/teams", + "hooks_url": "https://api.github.com/repos/bogus/bogus/hooks", + "issue_events_url": "https://api.github.com/repos/bogus/bogus/issues/events{/number}", + "events_url": "https://api.github.com/repos/bogus/bogus/events", + "assignees_url": "https://api.github.com/repos/bogus/bogus/assignees{/user}", + "branches_url": "https://api.github.com/repos/bogus/bogus/branches{/branch}", + "tags_url": "https://api.github.com/repos/bogus/bogus/tags", + "blobs_url": "https://api.github.com/repos/bogus/bogus/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/bogus/bogus/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/bogus/bogus/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/bogus/bogus/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/bogus/bogus/statuses/{sha}", + "languages_url": "https://api.github.com/repos/bogus/bogus/languages", + "stargazers_url": "https://api.github.com/repos/bogus/bogus/stargazers", + "contributors_url": "https://api.github.com/repos/bogus/bogus/contributors", + "subscribers_url": "https://api.github.com/repos/bogus/bogus/subscribers", + "subscription_url": "https://api.github.com/repos/bogus/bogus/subscription", + "commits_url": "https://api.github.com/repos/bogus/bogus/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/bogus/bogus/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/bogus/bogus/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/bogus/bogus/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/bogus/bogus/contents/{+path}", + "compare_url": "https://api.github.com/repos/bogus/bogus/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/bogus/bogus/merges", + "archive_url": "https://api.github.com/repos/bogus/bogus/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/bogus/bogus/downloads", + "issues_url": "https://api.github.com/repos/bogus/bogus/issues{/number}", + "pulls_url": "https://api.github.com/repos/bogus/bogus/pulls{/number}", + "milestones_url": "https://api.github.com/repos/bogus/bogus/milestones{/number}", + "notifications_url": "https://api.github.com/repos/bogus/bogus/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/bogus/bogus/labels{/name}", + "releases_url": "https://api.github.com/repos/bogus/bogus/releases{/id}", + "deployments_url": "https://api.github.com/repos/bogus/bogus/deployments", + "created_at": "2018-09-06T03:25:38Z", + "updated_at": "2018-09-30T22:04:06Z", + "pushed_at": "2019-08-08T22:22:34Z", + "git_url": "git://github.com/bogus/bogus.git", + "ssh_url": "git@github.com:bogus/bogus.git", + "clone_url": "https://github.com/bogus/bogus.git", + "svn_url": "https://github.com/bogus/bogus", + "homepage": null, + "size": 618, + "stargazers_count": 0, + "watchers_count": 0, + "language": "Java", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 5, + "license": null, + "forks": 0, + "open_issues": 5, + "watchers": 0, + "default_branch": "main" + } + ] +} diff --git a/src/test/resources/__files/get-app.json b/src/test/resources/__files/get-app.json new file mode 100644 index 00000000..446fc197 --- /dev/null +++ b/src/test/resources/__files/get-app.json @@ -0,0 +1,41 @@ +{ + "id": 11111, + "node_id": "MDM6QXBwMzI2MTY=", + "owner": { + "login": "bogus", + "id": 111111111, + "node_id": "asdfasdfasdf", + "avatar_url": "https://avatars2.githubusercontent.com/u/111111111?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/bogus", + "html_url": "https://github.com/bogus", + "followers_url": "https://api.github.com/users/bogus/followers", + "following_url": "https://api.github.com/users/bogus/following{/other_user}", + "gists_url": "https://api.github.com/users/bogus/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bogus/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bogus/subscriptions", + "organizations_url": "https://api.github.com/users/bogus/orgs", + "repos_url": "https://api.github.com/users/bogus/repos", + "events_url": "https://api.github.com/users/bogus/events{/privacy}", + "received_events_url": "https://api.github.com/users/bogus/received_events", + "type": "Organization", + "site_admin": false + }, + "name": "Bogus-Development", + "description": "", + "external_url": "https://bogus.domain.com", + "html_url": "https://github.com/apps/bogus-development", + "created_at": "2019-06-10T04:21:41Z", + "updated_at": "2019-06-10T04:21:41Z", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read", + "pull_requests": "write" + }, + "events": [ + "pull_request", + "push" + ], + "installations_count": 1 +} diff --git a/src/test/resources/__files/list-installations.json b/src/test/resources/__files/list-installations.json new file mode 100644 index 00000000..89f61647 --- /dev/null +++ b/src/test/resources/__files/list-installations.json @@ -0,0 +1,45 @@ +[ + { + "id": 11111111, + "account": { + "login": "bogus", + "id": 111111111, + "node_id": "asdfasdfasdf", + "avatar_url": "https://avatars2.githubusercontent.com/u/111111111?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/bogus", + "html_url": "https://github.com/bogus", + "followers_url": "https://api.github.com/users/bogus/followers", + "following_url": "https://api.github.com/users/bogus/following{/other_user}", + "gists_url": "https://api.github.com/users/bogus/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bogus/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bogus/subscriptions", + "organizations_url": "https://api.github.com/users/bogus/orgs", + "repos_url": "https://api.github.com/users/bogus/repos", + "events_url": "https://api.github.com/users/bogus/events{/privacy}", + "received_events_url": "https://api.github.com/users/bogus/received_events", + "type": "Organization", + "site_admin": false + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/11111111/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/bogus/settings/installations/11111111", + "app_id": 11111, + "target_id": 111111111, + "target_type": "Organization", + "permissions": { + "checks": "write", + "pull_requests": "write", + "contents": "read", + "metadata": "read" + }, + "events": [ + "pull_request", + "push" + ], + "created_at": "2019-07-04T01:19:36.000Z", + "updated_at": "2019-07-30T22:48:09.000Z", + "single_file_name": null + } +] diff --git a/src/test/resources/__files/rate-limit.json b/src/test/resources/__files/rate-limit.json new file mode 100644 index 00000000..03c443d8 --- /dev/null +++ b/src/test/resources/__files/rate-limit.json @@ -0,0 +1,29 @@ +{ + "resources": { + "core": { + "limit": 5000, + "remaining": 4944, + "reset": 1570055937 + }, + "search": { + "limit": 30, + "remaining": 30, + "reset": 1570052463 + }, + "graphql": { + "limit": 5000, + "remaining": 5000, + "reset": 1570056003 + }, + "integration_manifest": { + "limit": 5000, + "remaining": 5000, + "reset": 1570056003 + } + }, + "rate": { + "limit": 5000, + "remaining": 4944, + "reset": 1570055937 + } +} diff --git a/src/test/resources/mappings/create-access-token.json b/src/test/resources/mappings/create-access-token.json new file mode 100644 index 00000000..bf9be8ce --- /dev/null +++ b/src/test/resources/mappings/create-access-token.json @@ -0,0 +1,10 @@ +{ + "request": { + "url": "/app/installations/11111111/access_tokens", + "method": "POST" + }, + "response": { + "status": 200, + "bodyFileName": "create-access-token.json" + } +} diff --git a/src/test/resources/mappings/get-app.json b/src/test/resources/mappings/get-app.json new file mode 100644 index 00000000..2eb5c532 --- /dev/null +++ b/src/test/resources/mappings/get-app.json @@ -0,0 +1,10 @@ +{ + "request": { + "url": "/app", + "method": "GET" + }, + "response": { + "status": 200, + "bodyFileName": "get-app.json" + } +} diff --git a/src/test/resources/mappings/list-installations.json b/src/test/resources/mappings/list-installations.json new file mode 100644 index 00000000..ea48a695 --- /dev/null +++ b/src/test/resources/mappings/list-installations.json @@ -0,0 +1,10 @@ +{ + "request": { + "url": "/app/installations", + "method": "GET" + }, + "response": { + "status": 200, + "bodyFileName": "list-installations.json" + } +} diff --git a/src/test/resources/mappings/rate-limit.json b/src/test/resources/mappings/rate-limit.json new file mode 100644 index 00000000..a5acd207 --- /dev/null +++ b/src/test/resources/mappings/rate-limit.json @@ -0,0 +1,43 @@ +{ + "id": "195911b7-d3b7-4eb4-9d1e-f39465b89bb8", + "name": "rate_limit", + "request": { + "url": "/rate_limit", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github.v3+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "rate_limit-1.json", + "headers": { + "Date": "Wed, 02 Oct 2019 21:40:03 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "200 OK", + "X-RateLimit-Limit": "5000", + "X-RateLimit-Remaining": "4944", + "X-RateLimit-Reset": "1570055937", + "Cache-Control": "no-cache", + "X-OAuth-Scopes": "admin:org, admin:org_hook, admin:public_key, admin:repo_hook, delete_repo, gist, notifications, repo, user, write:discussion", + "X-Accepted-OAuth-Scopes": "", + "X-GitHub-Media-Type": "unknown, github.v3", + "Access-Control-Expose-Headers": "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type", + "Access-Control-Allow-Origin": "*", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "Vary": "Accept-Encoding", + "X-GitHub-Request-Id": "C2CD:3CA2:BB7551:E0A7D6:5D951933" + } + }, + "uuid": "195911b7-d3b7-4eb4-9d1e-f39465b89bb8", + "persistent": true, + "insertionIndex": 1 +}