From 79148a9f72456de2c4cf1de73cf769a2206ed9d9 Mon Sep 17 00:00:00 2001 From: Adrien Lecharpentier Date: Wed, 4 Dec 2024 17:39:03 +0100 Subject: [PATCH] Detects if a plugin repository is archived or not (#572) --- .../probes/RepositoryArchivedStatusProbe.java | 76 ++++++++++ .../scores/DeprecatedPluginScoring.java | 99 ++++++++----- .../pluginhealth/scoring/scores/Scoring.java | 2 +- .../RepositoryArchivedStatusProbeTest.java | 140 ++++++++++++++++++ .../scores/DeprecatedPluginScoringTest.java | 60 +++++++- 5 files changed, 334 insertions(+), 43 deletions(-) create mode 100644 core/src/main/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbe.java create mode 100644 core/src/test/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbeTest.java diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbe.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbe.java new file mode 100644 index 000000000..7e29d3350 --- /dev/null +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbe.java @@ -0,0 +1,76 @@ +/* + * MIT License + * + * Copyright (c) 2024 Jenkins Infra + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.jenkins.pluginhealth.scoring.probes; + +import java.io.IOException; +import java.util.Optional; + +import io.jenkins.pluginhealth.scoring.model.Plugin; +import io.jenkins.pluginhealth.scoring.model.ProbeResult; + +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@Order(value = RepositoryArchivedStatusProbe.ORDER) +public class RepositoryArchivedStatusProbe extends Probe { + public static final int ORDER = SCMLinkValidationProbe.ORDER + 100; + public static final String KEY = "repository-archived"; + + @Override + protected ProbeResult doApply(Plugin plugin, ProbeContext context) { + if (plugin.getScm() == null) { + return this.error("Plugin SCM is unknown, cannot fetch the number of open pull requests."); + } + final GitHub gh = context.getGitHub(); + final Optional repositoryName = context.getRepositoryName(); + if (repositoryName.isEmpty()) { + return this.error("Cannot find repository for " + plugin.getName()); + } + + try { + final GHRepository repository = gh.getRepository(repositoryName.get()); + return this.success("%b".formatted(repository.isArchived())); + } catch (IOException e) { + return this.error("Cannot access repository " + repositoryName.get()); + } + } + + @Override + public String key() { + return KEY; + } + + @Override + public String getDescription() { + return "Learn if the plugin repository is archived or not."; + } + + @Override + public long getVersion() { + return 1; + } +} diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoring.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoring.java index 5993a7f6f..ba3262347 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoring.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoring.java @@ -21,7 +21,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - package io.jenkins.pluginhealth.scoring.scores; import java.util.List; @@ -32,6 +31,7 @@ import io.jenkins.pluginhealth.scoring.model.Resolution; import io.jenkins.pluginhealth.scoring.model.ScoringComponentResult; import io.jenkins.pluginhealth.scoring.probes.DeprecatedPluginProbe; +import io.jenkins.pluginhealth.scoring.probes.RepositoryArchivedStatusProbe; import org.springframework.stereotype.Component; @@ -43,42 +43,73 @@ public class DeprecatedPluginScoring extends Scoring { @Override public List getComponents() { return List.of( - new ScoringComponent() { - @Override - public String getDescription() { - return "The plugin must not be marked as deprecated."; - } + new ScoringComponent() { + @Override + public String getDescription() { + return "The plugin must not be marked as deprecated."; + } + + @Override + public ScoringComponentResult getScore(Plugin $, Map probeResults) { + final ProbeResult probeResult = probeResults.get(DeprecatedPluginProbe.KEY); + if (probeResult == null) { + return new ScoringComponentResult( + 0, + getWeight(), + List.of("Cannot determine if the plugin is marked as deprecated or not.")); + } - @Override - public ScoringComponentResult getScore(Plugin $, Map probeResults) { - final ProbeResult probeResult = probeResults.get(DeprecatedPluginProbe.KEY); - if (probeResult == null) { - return new ScoringComponentResult(0, getWeight(), List.of("Cannot determine if the plugin is marked as deprecated or not.")); + return switch (probeResult.message()) { + case "This plugin is marked as deprecated." -> new ScoringComponentResult( + 0, + getWeight(), + List.of("Plugin is marked as deprecated."), + List.of( + new Resolution( + "See deprecation guidelines", + "https://www.jenkins.io/doc/developer/plugin-governance/deprecating-or-removing-plugin/"))); + case "This plugin is NOT deprecated." -> new ScoringComponentResult( + 100, 0, List.of("Plugin is not marked as deprecated.")); + default -> new ScoringComponentResult( + 0, + getWeight(), + List.of( + "Cannot determine if the plugin is marked as deprecated or not.", + probeResult.message())); + }; } - return switch (probeResult.message()) { - case "This plugin is marked as deprecated." -> - new ScoringComponentResult( - 0, - getWeight(), - List.of("Plugin is marked as deprecated."), - List.of( - new Resolution("See deprecation guidelines", "https://www.jenkins.io/doc/developer/plugin-governance/deprecating-or-removing-plugin/") - ) - ); - case "This plugin is NOT deprecated." -> - new ScoringComponentResult(100, getWeight(), List.of("Plugin is not marked as deprecated.")); - default -> - new ScoringComponentResult(0, getWeight(), List.of("Cannot determine if the plugin is marked as deprecated or not.", probeResult.message())); - }; - } + @Override + public int getWeight() { + return 1; + } + }, + new ScoringComponent() { + @Override + public String getDescription() { + return "The plugin's repository must not be archived"; + } - @Override - public int getWeight() { - return 1; - } - } - ); + @Override + public ScoringComponentResult getScore(Plugin plugin, Map probeResults) { + final ProbeResult probeResult = probeResults.get(RepositoryArchivedStatusProbe.KEY); + if (probeResult == null || ProbeResult.Status.ERROR.equals(probeResult.status())) { + return new ScoringComponentResult( + -100, 100, List.of("Cannot determine if the repository is archived or not.")); + } + + final boolean isArchived = Boolean.parseBoolean(probeResult.message()); + return isArchived + ? new ScoringComponentResult( + 0, getWeight(), List.of("The plugin repository is archived.")) + : new ScoringComponentResult(100, 0, List.of("The repository is not archived.")); + } + + @Override + public int getWeight() { + return 1; + } + }); } @Override @@ -98,6 +129,6 @@ public String description() { @Override public int version() { - return 3; + return 4; } } diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/Scoring.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/Scoring.java index 486d50472..eb9f0c810 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/Scoring.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/Scoring.java @@ -103,7 +103,7 @@ public Function, ScoreResult> finisher() { .sum(); return new ScoreResult( key(), - (int) Math.max(0, Math.round(sum / weight)), + weight == 0 ? 100 : (int) Math.max(0, Math.round(sum / weight)), weight(), changelogResults, version()); diff --git a/core/src/test/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbeTest.java b/core/src/test/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbeTest.java new file mode 100644 index 000000000..65b0b401d --- /dev/null +++ b/core/src/test/java/io/jenkins/pluginhealth/scoring/probes/RepositoryArchivedStatusProbeTest.java @@ -0,0 +1,140 @@ +/* + * MIT License + * + * Copyright (c) 2024 Jenkins Infra + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.jenkins.pluginhealth.scoring.probes; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import io.jenkins.pluginhealth.scoring.model.Plugin; +import io.jenkins.pluginhealth.scoring.model.ProbeResult; + +import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; + +class RepositoryArchivedStatusProbeTest extends AbstractProbeTest { + + @Override + RepositoryArchivedStatusProbe getSpy() { + return spy(RepositoryArchivedStatusProbe.class); + } + + @Test + void shouldNotRequireRelease() { + assertFalse(getSpy().requiresRelease()); + } + + @Test + void shouldNotBeRelatedToSourceCode() { + assertFalse(getSpy().requiresRelease()); + } + + @Test + void shouldFailWithNoSCM() { + final Plugin pl = mock(Plugin.class); + final ProbeContext ctx = mock(ProbeContext.class); + + final RepositoryArchivedStatusProbe probe = getSpy(); + + assertThat(probe.apply(pl, ctx)) + .usingRecursiveComparison() + .comparingOnlyFields("id", "status", "message") + .isEqualTo(ProbeResult.error( + RepositoryArchivedStatusProbe.KEY, + "Plugin SCM is unknown, cannot fetch the number of open pull requests.", + 1)); + } + + @Test + void shouldFailWithNoRepositoryName() { + final Plugin pl = mock(Plugin.class); + final ProbeContext ctx = mock(ProbeContext.class); + + when(pl.getName()).thenReturn("_test_"); + when(pl.getScm()).thenReturn("valid-url"); + when(ctx.getRepositoryName()).thenReturn(Optional.empty()); + + final RepositoryArchivedStatusProbe probe = getSpy(); + + assertThat(probe.apply(pl, ctx)) + .usingRecursiveComparison() + .comparingOnlyFields("id", "status", "message") + .isEqualTo( + ProbeResult.error(RepositoryArchivedStatusProbe.KEY, "Cannot find repository for _test_", 1)); + } + + @Test + void shouldSucceedToFindArchivedRepository() throws Exception { + final Plugin pl = mock(Plugin.class); + final ProbeContext ctx = mock(ProbeContext.class); + final GitHub gh = mock(GitHub.class); + final GHRepository ghRepository = mock(GHRepository.class); + + when(pl.getName()).thenReturn("_test_"); + when(pl.getScm()).thenReturn("valid-url"); + when(ctx.getRepositoryName()).thenReturn(Optional.of("jenkinsci/_test_")); + + when(ctx.getGitHub()).thenReturn(gh); + when(gh.getRepository(anyString())).thenReturn(ghRepository); + + when(ghRepository.isArchived()).thenReturn(true); + + final RepositoryArchivedStatusProbe probe = getSpy(); + + assertThat(probe.apply(pl, ctx)) + .usingRecursiveComparison() + .comparingOnlyFields("id", "status", "message") + .isEqualTo(ProbeResult.success(RepositoryArchivedStatusProbe.KEY, "true", 1)); + } + + @Test + void shouldSucceedToFindNotArchivedRepository() throws Exception { + final Plugin pl = mock(Plugin.class); + final ProbeContext ctx = mock(ProbeContext.class); + final GitHub gh = mock(GitHub.class); + final GHRepository ghRepository = mock(GHRepository.class); + + when(pl.getName()).thenReturn("_test_"); + when(pl.getScm()).thenReturn("valid-url"); + when(ctx.getRepositoryName()).thenReturn(Optional.of("jenkinsci/_test_")); + + when(ctx.getGitHub()).thenReturn(gh); + when(gh.getRepository(anyString())).thenReturn(ghRepository); + + when(ghRepository.isArchived()).thenReturn(false); + + final RepositoryArchivedStatusProbe probe = getSpy(); + + assertThat(probe.apply(pl, ctx)) + .usingRecursiveComparison() + .comparingOnlyFields("id", "status", "message") + .isEqualTo(ProbeResult.success(RepositoryArchivedStatusProbe.KEY, "false", 1)); + } +} diff --git a/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoringTest.java b/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoringTest.java index a061638cb..7f1f745a6 100644 --- a/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoringTest.java +++ b/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/DeprecatedPluginScoringTest.java @@ -1,7 +1,7 @@ /* * MIT License * - * Copyright (c) 2023 Jenkins Infra + * Copyright (c) 2023-2024 Jenkins Infra * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -21,7 +21,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - package io.jenkins.pluginhealth.scoring.scores; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; @@ -35,6 +34,7 @@ import io.jenkins.pluginhealth.scoring.model.ProbeResult; import io.jenkins.pluginhealth.scoring.model.ScoreResult; import io.jenkins.pluginhealth.scoring.probes.DeprecatedPluginProbe; +import io.jenkins.pluginhealth.scoring.probes.RepositoryArchivedStatusProbe; import org.junit.jupiter.api.Test; @@ -49,9 +49,12 @@ void shouldScoreCorrectlyNotDeprecatedPlugin() { final Plugin plugin = mock(Plugin.class); final DeprecatedPluginScoring scoring = getSpy(); - when(plugin.getDetails()).thenReturn(Map.of( - DeprecatedPluginProbe.KEY, ProbeResult.success(DeprecatedPluginProbe.KEY, "This plugin is NOT deprecated.", 1) - )); + when(plugin.getDetails()) + .thenReturn(Map.of( + DeprecatedPluginProbe.KEY, + ProbeResult.success(DeprecatedPluginProbe.KEY, "This plugin is NOT deprecated.", 1), + RepositoryArchivedStatusProbe.KEY, + ProbeResult.success(RepositoryArchivedStatusProbe.KEY, "false", 1))); final ScoreResult result = scoring.apply(plugin); @@ -79,9 +82,50 @@ void shouldScoreCorrectlyDeprecatedPlugin() { final Plugin plugin = mock(Plugin.class); final DeprecatedPluginScoring scoring = getSpy(); - when(plugin.getDetails()).thenReturn(Map.of( - DeprecatedPluginProbe.KEY, ProbeResult.success(DeprecatedPluginProbe.KEY, "This plugin is marked as deprecated.", 1) - )); + when(plugin.getDetails()) + .thenReturn(Map.of( + DeprecatedPluginProbe.KEY, + ProbeResult.success(DeprecatedPluginProbe.KEY, "This plugin is marked as deprecated.", 1), + RepositoryArchivedStatusProbe.KEY, + ProbeResult.success(RepositoryArchivedStatusProbe.KEY, "false", 1))); + + final ScoreResult result = scoring.apply(plugin); + + assertThat(result.key()).isEqualTo("deprecation"); + assertThat(result.weight()).isEqualTo(.8f); + assertThat(result.value()).isEqualTo(0); + } + + @Test + void shouldScoreCorrectlyArchivedRepository() { + final Plugin plugin = mock(Plugin.class); + final DeprecatedPluginScoring scoring = getSpy(); + + when(plugin.getDetails()) + .thenReturn(Map.of( + DeprecatedPluginProbe.KEY, + ProbeResult.success(DeprecatedPluginProbe.KEY, "This plugin is NOT deprecated.", 1), + RepositoryArchivedStatusProbe.KEY, + ProbeResult.success(RepositoryArchivedStatusProbe.KEY, "true", 1))); + + final ScoreResult result = scoring.apply(plugin); + + assertThat(result.key()).isEqualTo("deprecation"); + assertThat(result.weight()).isEqualTo(.8f); + assertThat(result.value()).isEqualTo(0); + } + + @Test + void shouldScoreCorrectlyArchivedRepositoryAndDeprecatedPlugin() { + final Plugin plugin = mock(Plugin.class); + final DeprecatedPluginScoring scoring = getSpy(); + + when(plugin.getDetails()) + .thenReturn(Map.of( + DeprecatedPluginProbe.KEY, + ProbeResult.success(DeprecatedPluginProbe.KEY, "This plugin is marked as deprecated.", 1), + RepositoryArchivedStatusProbe.KEY, + ProbeResult.success(RepositoryArchivedStatusProbe.KEY, "true", 1))); final ScoreResult result = scoring.apply(plugin);