From 76202c49e70d4a9b5cf07e3ec332163511edc4ad Mon Sep 17 00:00:00 2001 From: Adrien Lecharpentier Date: Tue, 13 Feb 2024 17:35:56 +0100 Subject: [PATCH] Provides link to help / guide how to improve scores (#452) --- .../pluginhealth/scoring/model/Score.java | 4 +- .../scoring/model/ScoringComponentResult.java | 14 ++-- .../scoring/scores/AdoptionScoring.java | 11 ++- .../scores/DeprecatedPluginScoring.java | 11 ++- .../scores/PluginMaintenanceScoring.java | 54 ++++++++---- .../scoring/scores/ScoringComponent.java | 4 +- .../scores/PluginMaintenanceScoringTest.java | 6 +- .../pluginhealth/scoring/http/ScoreAPI.java | 39 +++++---- .../scoring/scores/ScoringEngine.java | 4 +- .../scoring/scores/ScoringEngineTest.java | 84 +++++++++++++++++-- 10 files changed, 170 insertions(+), 61 deletions(-) diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/model/Score.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/model/Score.java index 792a4359e..ba95ad384 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/model/Score.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/model/Score.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 @@ -84,9 +84,11 @@ public long getValue() { private void computeValue() { var sum = details.stream() + .filter(Objects::nonNull) .flatMapToDouble(res -> DoubleStream.of(res.value() * res.weight())) .sum(); var coefficient = details.stream() + .filter(Objects::nonNull) .flatMapToDouble(res -> DoubleStream.of(res.weight())) .sum(); this.value = Math.round((sum / coefficient)); diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/model/ScoringComponentResult.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/model/ScoringComponentResult.java index 82735491c..45f240986 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/model/ScoringComponentResult.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/model/ScoringComponentResult.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 @@ -31,9 +31,13 @@ /** * Describes the evaluation from a {@link ScoringComponent} on a specific plugin. * - * @param score the score representing the points granted to the plugin, out of 100 (one hundred). - * @param weight the weight of the score - * @param reasons the list of string explaining the score granted to the plugin + * @param score the score representing the points granted to the plugin, out of 100 (one hundred). + * @param weight the weight of the score + * @param reasons the list of string explaining the score granted to the plugin + * @param resolutions the list of link providing help to increase the score */ -public record ScoringComponentResult(int score, float weight, List reasons) { +public record ScoringComponentResult(int score, float weight, List reasons, List resolutions) { + public ScoringComponentResult(int score, float weight, List reasons) { + this(score, weight, reasons, List.of()); + } } diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/AdoptionScoring.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/AdoptionScoring.java index 252447d02..b6789cdee 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/AdoptionScoring.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/AdoptionScoring.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 @@ -83,7 +83,12 @@ public ScoringComponentResult getScore(Plugin $, Map probeR case "This plugin is not up for adoption." -> new ScoringComponentResult(100, getWeight(), List.of("The plugin is not marked as up for adoption.")); case "This plugin is up for adoption." -> - new ScoringComponentResult(-1000, getWeight(), List.of("The plugin is marked as up for adoption.")); + new ScoringComponentResult( + -1000, + getWeight(), + List.of("The plugin is marked as up for adoption."), + List.of("https://www.jenkins.io/doc/developer/plugin-governance/adopt-a-plugin/#plugins-marked-for-adoption") + ); default -> new ScoringComponentResult(-100, getWeight(), List.of()); }; } @@ -142,6 +147,6 @@ public String description() { @Override public int version() { - return 2; + return 3; } } 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 123404e2f..dc8335d06 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 @@ -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 @@ -57,7 +57,12 @@ public ScoringComponentResult getScore(Plugin $, Map probeR return switch (probeResult.message()) { case "This plugin is marked as deprecated." -> - new ScoringComponentResult(0, getWeight(), List.of("Plugin is marked as deprecated.")); + new ScoringComponentResult( + 0, + getWeight(), + List.of("Plugin is marked as deprecated."), + List.of("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 -> @@ -90,6 +95,6 @@ public String description() { @Override public int version() { - return 1; + return 2; } } diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoring.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoring.java index d98162cd4..73f6c9068 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoring.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoring.java @@ -63,7 +63,12 @@ public ScoringComponentResult getScore(Plugin $, Map probeR case "Jenkinsfile found" -> new ScoringComponentResult(100, getWeight(), List.of("Jenkinsfile detected in plugin repository.")); case "No Jenkinsfile found" -> - new ScoringComponentResult(0, getWeight(), List.of("Jenkinsfile not detected in plugin repository.")); + new ScoringComponentResult( + 0, + getWeight(), + List.of("Jenkinsfile not detected in plugin repository."), + List.of("https://www.jenkins.io/doc/developer/tutorial-improve/add-a-jenkinsfile/") + ); default -> new ScoringComponentResult(0, getWeight(), List.of("Cannot confirm or not the presence of Jenkinsfile.", probeResult.message())); }; @@ -90,7 +95,12 @@ public ScoringComponentResult getScore(Plugin $, Map probeR case "Documentation is located in the plugin repository." -> new ScoringComponentResult(100, getWeight(), List.of("Documentation is in plugin repository.")); case "Documentation is not located in the plugin repository." -> - new ScoringComponentResult(0, getWeight(), List.of("Documentation should be migrated in plugin repository.")); + new ScoringComponentResult( + 0, + getWeight(), + List.of("Documentation should be migrated in plugin repository."), + List.of("https://www.jenkins.io/doc/developer/tutorial-improve/migrate-documentation-to-github/") + ); default -> new ScoringComponentResult(0, getWeight(), List.of("Cannot confirm or not the documentation migration.", probeResult.message())); }; @@ -108,7 +118,7 @@ public String getDescription() { } @Override - public ScoringComponentResult getScore(Plugin $, Map probeResults) { + public ScoringComponentResult getScore(Plugin pl, Map probeResults) { final ProbeResult dependabot = probeResults.get(DependabotProbe.KEY); final ProbeResult renovate = probeResults.get(RenovateProbe.KEY); final ProbeResult dependencyPullRequest = probeResults.get(DependabotPullRequestProbe.KEY); @@ -118,31 +128,41 @@ public ScoringComponentResult getScore(Plugin $, Map probeR } if (dependabot != null && ProbeResult.Status.SUCCESS.equals(dependabot.status()) && "Dependabot is configured.".equals(dependabot.message())) { - return manageOpenDependencyPullRequestValue(dependabot, dependencyPullRequest); + return manageOpenDependencyPullRequestValue(pl, dependabot, dependencyPullRequest); } if (renovate != null && ProbeResult.Status.SUCCESS.equals(renovate.status()) && "Renovate is configured.".equals(renovate.message())) { - return manageOpenDependencyPullRequestValue(renovate, dependencyPullRequest); + return manageOpenDependencyPullRequestValue(pl, renovate, dependencyPullRequest); } - return new ScoringComponentResult(0, getWeight(), List.of("No dependency version manager bot are used on the plugin repository.")); + return new ScoringComponentResult( + 0, + getWeight(), + List.of("No dependency version manager bot are used on the plugin repository."), + List.of("https://www.jenkins.io/doc/developer/tutorial-improve/automate-dependency-update-checks/") + ); } - private ScoringComponentResult manageOpenDependencyPullRequestValue(ProbeResult dependencyBotResult, ProbeResult dependencyPullRequestResult) { - if (dependencyPullRequestResult != null && "0".equals(dependencyPullRequestResult.message())) { - return new ScoringComponentResult( - 100, - getWeight(), - List.of(dependencyBotResult.message(), "%s open dependency pull request".formatted(dependencyPullRequestResult.message())) - ); + private ScoringComponentResult manageOpenDependencyPullRequestValue(Plugin plugin, ProbeResult dependencyBotResult, ProbeResult dependencyPullRequestResult) { + if (dependencyPullRequestResult != null) { + return "0".equals(dependencyPullRequestResult.message()) ? + new ScoringComponentResult( + 100, + getWeight(), + List.of(dependencyBotResult.message(), "%s open dependency pull request".formatted(dependencyPullRequestResult.message())) + ) : + new ScoringComponentResult( + 50, + getWeight(), + List.of(dependencyBotResult.message(), "%s open dependency pull request".formatted(dependencyPullRequestResult.message())), + List.of("%s/pulls?q=is%%3Aopen+is%%3Apr+label%%3Adependencies".formatted(plugin.getScm())) + ); } return new ScoringComponentResult( 0, getWeight(), List.of( dependencyBotResult.message(), - dependencyPullRequestResult == null ? - "Cannot determine if there is any dependency pull request opened on the repository." : - "%s open dependency pull requests".formatted(dependencyPullRequestResult.message()) + "Cannot determine if there is any dependency pull request opened on the repository." ) ); } @@ -201,6 +221,6 @@ public String description() { @Override public int version() { - return 1; + return 2; } } diff --git a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringComponent.java b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringComponent.java index 1d1412842..b9a467ab2 100644 --- a/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringComponent.java +++ b/core/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringComponent.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 @@ -32,7 +32,7 @@ public interface ScoringComponent { /** - * Provides a human readable description of the behavior of the Changelog. + * Provides a human-readable description of the behavior of the Changelog. * * @return the description of the implementation. */ diff --git a/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoringTest.java b/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoringTest.java index 28282b59b..d2b9dace7 100644 --- a/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoringTest.java +++ b/core/src/test/java/io/jenkins/pluginhealth/scoring/scores/PluginMaintenanceScoringTest.java @@ -122,7 +122,7 @@ static Stream probeResultsAndValue() { ContinuousDeliveryProbe.KEY, ProbeResult.success(ContinuousDeliveryProbe.KEY, "Could not find JEP-229 workflow definition.", 1), DocumentationMigrationProbe.KEY, ProbeResult.success(DocumentationMigrationProbe.KEY, "Documentation is not located in the plugin repository.", 1) ), - 65 + 73 ), arguments(// Jenkinsfile and dependabot with no open pull request Map.of( @@ -160,7 +160,7 @@ static Stream probeResultsAndValue() { ContinuousDeliveryProbe.KEY, ProbeResult.success(ContinuousDeliveryProbe.KEY, "JEP-229 workflow definition found.", 1), DocumentationMigrationProbe.KEY, ProbeResult.success(DocumentationMigrationProbe.KEY, "Documentation is not located in the plugin repository.", 1) ), - 70 + 78 ), arguments(// Jenkinsfile and CD and dependabot with no open pull request Map.of( @@ -228,7 +228,7 @@ static Stream probeResultsAndValue() { ContinuousDeliveryProbe.KEY, ProbeResult.success(ContinuousDeliveryProbe.KEY, "Could not find JEP-229 workflow definition.", 1), DocumentationMigrationProbe.KEY, ProbeResult.success(DocumentationMigrationProbe.KEY, "Documentation is located in the plugin repository.", 1) ), - 15 + 23 ), arguments(// Documentation migration and Dependabot with no open pull requests Map.of( diff --git a/war/src/main/java/io/jenkins/pluginhealth/scoring/http/ScoreAPI.java b/war/src/main/java/io/jenkins/pluginhealth/scoring/http/ScoreAPI.java index 2db6a7c30..a0f2bb824 100644 --- a/war/src/main/java/io/jenkins/pluginhealth/scoring/http/ScoreAPI.java +++ b/war/src/main/java/io/jenkins/pluginhealth/scoring/http/ScoreAPI.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 @@ -29,6 +29,7 @@ import java.util.stream.Collectors; import io.jenkins.pluginhealth.scoring.model.ScoreResult; +import io.jenkins.pluginhealth.scoring.model.ScoringComponentResult; import io.jenkins.pluginhealth.scoring.service.ScoreService; import org.springframework.http.MediaType; @@ -45,7 +46,7 @@ public ScoreAPI(ScoreService scoreService) { this.scoreService = scoreService; } - @GetMapping(value = { "", "/" }, produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = {"", "/"}, produces = MediaType.APPLICATION_JSON_VALUE) public ScoreReport getReport() { final ScoreService.ScoreStatistics stats = scoreService.getScoresStatistics(); record Tuple(String name, PluginScoreSummary summary) { @@ -61,21 +62,10 @@ record Tuple(String name, PluginScoreSummary summary) { score.getValue(), score.getDetails().stream() .collect(Collectors.toMap( - ScoreResult::key, - scoreResult -> new PluginScoreDetail( - scoreResult.value(), - scoreResult.weight(), - scoreResult.componentsResults().stream() - .map(changelogResult -> - new PluginScoreDetailComponent( - changelogResult.score(), - changelogResult.weight(), - changelogResult.reasons() - ) - ) - .collect(Collectors.toList()) + ScoreResult::key, + PluginScoreDetail::new ) - )) + ) ) ); }) @@ -83,15 +73,28 @@ record Tuple(String name, PluginScoreSummary summary) { return new ScoreReport(plugins, stats); } - private record ScoreReport(Map plugins, ScoreService.ScoreStatistics statistics) { + public record ScoreReport(Map plugins, ScoreService.ScoreStatistics statistics) { } private record PluginScoreSummary(long value, Map details) { } private record PluginScoreDetail(float value, float weight, List components) { + private PluginScoreDetail(ScoreResult result) { + this( + result.value(), + result.weight(), + result.componentsResults().stream() + .map(PluginScoreDetailComponent::new) + .collect(Collectors.toList()) + ); + } } - private record PluginScoreDetailComponent(int value, float weight, List reasons) { + + private record PluginScoreDetailComponent(int value, float weight, List reasons, List resolutions) { + private PluginScoreDetailComponent(ScoringComponentResult result) { + this(result.score(), result.weight(), result.reasons(), result.resolutions()); + } } } diff --git a/war/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngine.java b/war/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngine.java index f684e6b03..8458a75e0 100644 --- a/war/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngine.java +++ b/war/src/main/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngine.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 @@ -78,7 +78,7 @@ public Score runOn(Plugin plugin) { if (latestScore.isPresent() && (latestProbeResult.isEmpty() || latestProbeResult.get().isBefore(latestScore.get().getComputedAt()))) { final Score score = latestScore.get(); boolean scoringIsSame = scoringService.getScoringList().stream() - .anyMatch(scoring -> + .allMatch(scoring -> score.getDetails().stream() .filter(sr -> sr.key().equals(scoring.key())) .findFirst() diff --git a/war/src/test/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngineTest.java b/war/src/test/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngineTest.java index 03bcad930..53ae8740b 100644 --- a/war/src/test/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngineTest.java +++ b/war/src/test/java/io/jenkins/pluginhealth/scoring/scores/ScoringEngineTest.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 @@ -74,8 +74,8 @@ void shouldBeAbleToScoreOnePlugin() { when(scoringService.getScoringList()).thenReturn(List.of(scoringA, scoringB)); when(scoreService.save(any(Score.class))).then(AdditionalAnswers.returnsFirstArg()); - final ScoringEngine ScoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); - final Score score = ScoringEngine.runOn(plugin); + final ScoringEngine scoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); + final Score score = scoringEngine.runOn(plugin); verify(scoringA).apply(plugin); verify(scoringB).apply(plugin); @@ -105,8 +105,8 @@ void shouldBeAbleToScoreMultiplePlugins() { when(scoringService.getScoringList()).thenReturn(List.of(scoringA, scoringB)); when(pluginService.streamAll()).thenReturn(Stream.of(pluginA, pluginB, pluginC)); - final ScoringEngine ScoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); - ScoringEngine.run(); + final ScoringEngine scoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); + scoringEngine.run(); final ArgumentCaptor pluginArgumentScoringA = ArgumentCaptor.forClass(Plugin.class); verify(scoringA, times(3)).apply(pluginArgumentScoringA.capture()); @@ -184,8 +184,8 @@ void shouldReRunScoringWhenVersionChanged() { )); when(scoreService.save(any(Score.class))).then(AdditionalAnswers.returnsFirstArg()); - final ScoringEngine ScoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); - final Score score = ScoringEngine.runOn(plugin); + final ScoringEngine scoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); + final Score score = scoringEngine.runOn(plugin); verify(scoringA).apply(plugin); @@ -194,4 +194,74 @@ void shouldReRunScoringWhenVersionChanged() { assertThat(score.getDetails()).hasSize(1).contains(expectedNewScoreResult); assertThat(score.getValue()).isEqualTo(100); } + + @Test + void shouldComputeScoreWhenNewScoringAvailable() { + final ZonedDateTime now = ZonedDateTime.now(); + + final Plugin plugin = mock(Plugin.class); + final Scoring scoringA = mock(Scoring.class); + final Scoring scoringB = mock(Scoring.class); + + String pluginName = "plugin"; + when(plugin.getName()).thenReturn(pluginName); + when(plugin.getDetails()).thenReturn(Map.of( + "probe-a", new ProbeResult("probe-a", "", ProbeResult.Status.SUCCESS, now.minusMinutes(10), 1) + )); + + when(scoringA.version()).thenReturn(1); + when(scoringA.key()).thenReturn("scoring-a"); + when(scoringB.key()).thenReturn("scoring-b"); + + Score previousScore = mock(Score.class); + when(previousScore.getDetails()).thenReturn(Set.of( + new ScoreResult("scoring-a", 1, 1, Set.of(), 1) + )); + when(previousScore.getComputedAt()).thenReturn(now.minusMinutes(5)); + when(scoreService.latestScoreFor(pluginName)).thenReturn(Optional.of(previousScore)); + + when(scoringService.getScoringList()).thenReturn(List.of(scoringA, scoringB)); + + final ScoringEngine scoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); + scoringEngine.runOn(plugin); + + verify(scoringA).apply(plugin); + verify(scoringB).apply(plugin); + } + + @Test + void shouldComputeScoreWhenNewScoringVersion() { + final ZonedDateTime now = ZonedDateTime.now(); + + final Plugin plugin = mock(Plugin.class); + final Scoring scoringA = mock(Scoring.class); + final Scoring scoringB = mock(Scoring.class); + + String pluginName = "plugin"; + when(plugin.getName()).thenReturn(pluginName); + when(plugin.getDetails()).thenReturn(Map.of( + "probe-a", new ProbeResult("probe-a", "", ProbeResult.Status.SUCCESS, now.minusMinutes(10), 1) + )); + + when(scoringA.version()).thenReturn(1); + when(scoringA.key()).thenReturn("scoring-a"); + when(scoringB.version()).thenReturn(2); + when(scoringB.key()).thenReturn("scoring-b"); + + Score previousScore = mock(Score.class); + when(previousScore.getDetails()).thenReturn(Set.of( + new ScoreResult("scoring-a", 1, 1, Set.of(), 1), + new ScoreResult("scoring-b", 1, 1, Set.of(), 1) + )); + when(previousScore.getComputedAt()).thenReturn(now.minusMinutes(5)); + when(scoreService.latestScoreFor(pluginName)).thenReturn(Optional.of(previousScore)); + + when(scoringService.getScoringList()).thenReturn(List.of(scoringA, scoringB)); + + final ScoringEngine scoringEngine = new ScoringEngine(scoringService, pluginService, scoreService); + scoringEngine.runOn(plugin); + + verify(scoringA).apply(plugin); + verify(scoringB).apply(plugin); + } }