Skip to content

Commit

Permalink
Uses newest scoring timestamp as etag header (#472)
Browse files Browse the repository at this point in the history
Co-authored-by: Hervé Le Meur <[email protected]>
  • Loading branch information
alecharp and lemeurherve authored Mar 5, 2024
1 parent ce0faa7 commit 4b9657a
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 6 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ node_modules/
### IDE
*.iml
*.idea

### fetch-report.sh
etags.txt
scores.json
60 changes: 60 additions & 0 deletions Jenkinsfile_report
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
def cronExpr = env.BRANCH_IS_PRIMARY ? 'H * * * *' : ''

def reportsFolder = 'plugin-health-scoring'
def etagsFile = 'etags.txt'
def reportFile = 'scores.json'
def reportLines = 0

pipeline {
agent any

options {
buildDiscarder(logRotator(numToKeepStr: '10'))
timeout(time: 5, unit: 'MINUTES')
disableConcurrentBuilds()
}

triggers {
cron( cronExpr )
}

stages {
stage('Fetch API') {
environment {
REPORTS_FOLDER = reportsFolder
ETAGS_FILE = etagsFile
REPORT_FILE = reportFile
URL = 'https://plugin-health.jenkins.io/api/scores'
}

steps {
reportLines = sh(returnStdout:true, script: '''
curl -LSsO https://reports.jenkins.io/${REPORTS_FOLDER}/${ETAGS_FILE} || echo "No previous etags file."
bash fetch-report.sh
mkdir -p "${reportsFolder}"
cp ${REPORT_FILE} ${ETAGS_FILE} ${REPORTS_FOLDER}

wc -l ${REPORT_FILE}
''').trim()
}

post {
success {
archiveArtifacts artifacts: "${reportFile}, ${etagsFile}"
}
}
}

stage('Publish') {
when {
expression {
infra.isInfra() && reportLines > 0
}
}

steps {
publishReports([ "${reportsFolder}/${reportFile}", "${reportsFolder}/${etagsFile}" ])
}
}
}
}
35 changes: 35 additions & 0 deletions fetch-report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euxo pipefail

#
# 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.
#

###
# using --compact-output to reduce output file by half.
# adding report generation date in 'lastUpdate' key of the report.
###
curl --etag-compare "${ETAGS_FILE}" \
--etag-save "${ETAGS_FILE}" \
-LSs "${URL}" \
| jq --compact-output '. + { lastUpdate: (now | todate) }' > "${REPORT_FILE}"
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,23 @@

package io.jenkins.pluginhealth.scoring.http;

import java.time.ZonedDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import io.jenkins.pluginhealth.scoring.model.Resolution;
import io.jenkins.pluginhealth.scoring.model.Score;
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.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -48,12 +55,18 @@ public ScoreAPI(ScoreService scoreService) {
}

@GetMapping(value = {"", "/"}, produces = MediaType.APPLICATION_JSON_VALUE)
public ScoreReport getReport() {
final ScoreService.ScoreStatistics stats = scoreService.getScoresStatistics();
public ResponseEntity<ScoreReport> getReport() {
final Map<String, Score> latestScoresSummaryMap = scoreService.getLatestScoresSummaryMap();
final Optional<String> optETag = latestScoresSummaryMap.values().stream()
.max(Comparator.comparing(Score::getComputedAt))
.map(Score::getComputedAt)
.map(ZonedDateTime::toEpochSecond)
.map(String::valueOf);

record Tuple(String name, PluginScoreSummary summary) {
}

final Map<String, PluginScoreSummary> plugins = scoreService.getLatestScoresSummaryMap()
final Map<String, PluginScoreSummary> plugins = latestScoresSummaryMap
.entrySet().stream()
.map(entry -> {
final var score = entry.getValue();
Expand All @@ -71,7 +84,12 @@ record Tuple(String name, PluginScoreSummary summary) {
);
})
.collect(Collectors.toMap(Tuple::name, Tuple::summary));
return new ScoreReport(plugins, stats);

final ResponseEntity.BodyBuilder bodyBuilder = ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
optETag.ifPresent(bodyBuilder::eTag);

return bodyBuilder.body(new ScoreReport(plugins, scoreService.getScoresStatistics()));
}

public record ScoreReport(Map<String, PluginScoreSummary> plugins, ScoreService.ScoreStatistics statistics) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@

package io.jenkins.pluginhealth.scoring.http;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.time.ZonedDateTime;
Expand All @@ -51,9 +54,11 @@
import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

@ExtendWith({ SpringExtension.class, MockitoExtension.class })
@ImportAutoConfiguration({ ProjectInfoAutoConfiguration.class, SecurityConfiguration.class })
Expand All @@ -70,12 +75,13 @@ void shouldBeAbleToProvideScoresSummary() throws Exception {
final Plugin p1 = mock(Plugin.class);
final Plugin p2 = mock(Plugin.class);

final Score scoreP1 = new Score(p1, ZonedDateTime.now());
final ZonedDateTime computedAt = ZonedDateTime.now().minusMinutes(1);
final Score scoreP1 = new Score(p1, computedAt);
scoreP1.addDetail(new ScoreResult("scoring-1", 100, 1, Set.of(
new ScoringComponentResult(100, 1, List.of("There is no active security advisory for the plugin."))
), 1));

final Score scoreP2 = new Score(p2, ZonedDateTime.now());
final Score scoreP2 = new Score(p2, ZonedDateTime.now().minusDays(1));
scoreP2.addDetail(new ScoreResult("scoring-1", 100, 1, Set.of(
new ScoringComponentResult(100, 1, List.of("There is no active security advisory for the plugin."))
), 1));
Expand All @@ -96,6 +102,7 @@ void shouldBeAbleToProvideScoresSummary() throws Exception {
mockMvc.perform(get("/api/scores"))
.andExpectAll(
status().isOk(),
header().string("ETag", equalTo("\"" + computedAt.toEpochSecond() + "\"")),
content().contentType(MediaType.APPLICATION_JSON),
content().json("""
{
Expand Down Expand Up @@ -161,4 +168,60 @@ void shouldBeAbleToProvideScoresSummary() throws Exception {
""", false)
);
}

@Test
void shouldGet304WhenNoNewScoring() throws Exception {
final Plugin plugin = mock(Plugin.class);

final ZonedDateTime computedAt = ZonedDateTime.now().minusHours(2);
final Score scoreP1 = new Score(plugin, computedAt);
scoreP1.addDetail(new ScoreResult("scoring-1", 100, 1, Set.of(
new ScoringComponentResult(100, 1, List.of("There is no active security advisory for the plugin."))
), 1));

when(scoreService.getLatestScoresSummaryMap()).thenReturn(Map.of(
"plugin-1", scoreP1
));

MvcResult mvcResult = mockMvc.perform(get("/api/scores"))
.andExpectAll(
status().isOk(),
header().string("ETag", equalTo("\"%s\"".formatted(computedAt.toEpochSecond()))),
content().contentType(MediaType.APPLICATION_JSON)
)
.andReturn();

final HttpHeaders httpHeaders = new HttpHeaders();
String responseETag = mvcResult.getResponse().getHeader("ETag");
assertThat(responseETag).isNotNull();
httpHeaders.setIfNoneMatch(responseETag);

mvcResult = mockMvc.perform(get("/api/scores").headers(httpHeaders))
.andExpectAll(
status().isNotModified(),
header().string("ETag", equalTo("\"%s\"".formatted(computedAt.toEpochSecond())))
)
.andReturn();

responseETag = mvcResult.getResponse().getHeader("ETag");
assertThat(responseETag).isNotNull();
httpHeaders.setIfNoneMatch(responseETag);

final ZonedDateTime newScoreComputedAt = ZonedDateTime.now().minusMinutes(5);
final Score newScoreP1 = new Score(plugin, newScoreComputedAt);
scoreP1.addDetail(new ScoreResult("scoring-1", 100, 1, Set.of(
new ScoringComponentResult(100, 1, List.of("There is no active security advisory for the plugin."))
), 1));

when(scoreService.getLatestScoresSummaryMap()).thenReturn(Map.of(
"plugin-1", newScoreP1
));

mockMvc.perform(get("/api/scores").headers(httpHeaders))
.andExpectAll(
status().isOk(),
header().string("ETag", equalTo("\"%s\"".formatted(newScoreComputedAt.toEpochSecond()))),
content().contentType(MediaType.APPLICATION_JSON)
);
}
}

0 comments on commit 4b9657a

Please sign in to comment.