From: Julien Lancelot Date: Mon, 15 Jan 2018 17:08:50 +0000 (+0100) Subject: SONAR-10266 Generate measure badge X-Git-Tag: 7.5~1784 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=26b7b078e22dd7aab61e24da4f41c2e7c82d75ad;p=sonarqube.git SONAR-10266 Generate measure badge --- diff --git a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/MeasureAction.java b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/MeasureAction.java index 6c3fcfa72b0..44d1b3fbc2d 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/MeasureAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/MeasureAction.java @@ -19,54 +19,192 @@ */ package org.sonar.server.badge.ws; +import com.google.common.collect.ImmutableMap; import com.google.common.io.Resources; -import org.apache.commons.io.IOUtils; -import org.sonar.api.measures.CoreMetrics; +import java.util.EnumMap; +import java.util.Map; +import java.util.function.Function; import org.sonar.api.server.ws.Request; import org.sonar.api.server.ws.Response; import org.sonar.api.server.ws.WebService; import org.sonar.api.server.ws.WebService.NewAction; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.measure.LiveMeasureDto; +import org.sonar.db.metric.MetricDto; +import org.sonar.server.badge.ws.SvgGenerator.Color; +import org.sonar.server.component.ComponentFinder; +import org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.String.format; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.commons.io.IOUtils.write; +import static org.sonar.api.measures.CoreMetrics.ALERT_STATUS_KEY; +import static org.sonar.api.measures.CoreMetrics.BUGS_KEY; +import static org.sonar.api.measures.CoreMetrics.CODE_SMELLS_KEY; +import static org.sonar.api.measures.CoreMetrics.COVERAGE_KEY; +import static org.sonar.api.measures.CoreMetrics.DUPLICATED_LINES_DENSITY_KEY; +import static org.sonar.api.measures.CoreMetrics.NCLOC_KEY; +import static org.sonar.api.measures.CoreMetrics.RELIABILITY_RATING_KEY; +import static org.sonar.api.measures.CoreMetrics.SECURITY_RATING_KEY; +import static org.sonar.api.measures.CoreMetrics.SQALE_RATING_KEY; +import static org.sonar.api.measures.CoreMetrics.TECHNICAL_DEBT_KEY; +import static org.sonar.api.measures.CoreMetrics.TESTS_KEY; +import static org.sonar.api.measures.CoreMetrics.VULNERABILITIES_KEY; +import static org.sonar.api.measures.Metric.Level; +import static org.sonar.api.measures.Metric.ValueType; +import static org.sonar.api.measures.Metric.Level.ERROR; +import static org.sonar.api.measures.Metric.Level.OK; +import static org.sonar.api.measures.Metric.Level.WARN; +import static org.sonar.api.web.UserRole.USER; +import static org.sonar.server.badge.ws.SvgFormatter.formatDuration; +import static org.sonar.server.badge.ws.SvgFormatter.formatNumeric; +import static org.sonar.server.badge.ws.SvgFormatter.formatPercent; +import static org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating.A; +import static org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating.B; +import static org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating.C; +import static org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating.D; +import static org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating.E; +import static org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating.valueOf; +import static org.sonar.server.ws.KeyExamples.KEY_BRANCH_EXAMPLE_001; import static org.sonar.server.ws.KeyExamples.KEY_PROJECT_EXAMPLE_001; +import static org.sonarqube.ws.MediaTypes.SVG; public class MeasureAction implements ProjectBadgesWsAction { - public static final String PARAM_COMPONENT = "component"; - public static final String PARAM_METRIC = "metric"; + private static final String PARAM_PROJECT = "project"; + private static final String PARAM_BRANCH = "branch"; + private static final String PARAM_METRIC = "metric"; + + private static final Map METRIC_NAME_BY_KEY = ImmutableMap.builder() + .put(ALERT_STATUS_KEY, "quality gate") + .put(COVERAGE_KEY, "coverage") + .put(RELIABILITY_RATING_KEY, "reliability") + .put(SECURITY_RATING_KEY, "security") + .put(SQALE_RATING_KEY, "maintainability") + .put(BUGS_KEY, "bugs") + .put(VULNERABILITIES_KEY, "vulnerabilities") + .put(CODE_SMELLS_KEY, "code smells") + .put(DUPLICATED_LINES_DENSITY_KEY, "duplicated lines") + .put(TECHNICAL_DEBT_KEY, "technical debt") + .put(TESTS_KEY, "unit tests") + .build(); + + private static final Map QUALITY_GATE_MESSAGE_BY_STATUS = new EnumMap<>(ImmutableMap.of( + OK, "passed", + WARN, "warning", + ERROR, "failed")); + + private static final Map COLOR_BY_QUALITY_GATE_STATUS = new EnumMap<>(ImmutableMap.of( + OK, Color.QUALITY_GATE_OK, + WARN, Color.QUALITY_GATE_WARN, + ERROR, Color.QUALITY_GATE_ERROR)); + + private static final Map COLOR_BY_RATING = new EnumMap<>(ImmutableMap.of( + A, Color.RATING_A, + B, Color.RATING_B, + C, Color.RATING_C, + D, Color.RATING_D, + E, Color.RATING_E)); + + private final UserSession userSession; + private final DbClient dbClient; + private final ComponentFinder componentFinder; + private final SvgGenerator svgGenerator; + + public MeasureAction(UserSession userSession, DbClient dbClient, ComponentFinder componentFinder, SvgGenerator svgGenerator) { + this.userSession = userSession; + this.dbClient = dbClient; + this.componentFinder = componentFinder; + this.svgGenerator = svgGenerator; + } @Override public void define(WebService.NewController controller) { NewAction action = controller.createAction("measure") .setHandler(this) - .setDescription("Generate badge for measure as an SVG") + .setDescription("Generate badge for project's measure as an SVG.
" + + "Requires 'Browse' permission on the specified project.") + .setSince("7.1") .setResponseExample(Resources.getResource(getClass(), "measure-example.svg")); - action.createParam(PARAM_COMPONENT) + action.createParam(PARAM_PROJECT) .setDescription("Project key") .setRequired(true) .setExampleValue(KEY_PROJECT_EXAMPLE_001); + action + .createParam(PARAM_BRANCH) + .setDescription("Branch key") + .setExampleValue(KEY_BRANCH_EXAMPLE_001); action.createParam(PARAM_METRIC) .setDescription("Metric key") .setRequired(true) - .setPossibleValues( - CoreMetrics.ALERT_STATUS_KEY, - CoreMetrics.COVERAGE_KEY, - CoreMetrics.RELIABILITY_RATING_KEY, - CoreMetrics.SECURITY_RATING_KEY, - CoreMetrics.SQALE_RATING_KEY, - CoreMetrics.BUGS_KEY, - CoreMetrics.VULNERABILITIES_KEY, - CoreMetrics.CODE_SMELLS_KEY, - CoreMetrics.DUPLICATED_LINES_DENSITY_KEY, - CoreMetrics.TECHNICAL_DEBT_KEY, - CoreMetrics.TESTS_KEY - ) - .setExampleValue(KEY_PROJECT_EXAMPLE_001); + .setPossibleValues(METRIC_NAME_BY_KEY.keySet()); } @Override public void handle(Request request, Response response) throws Exception { - response.stream().setMediaType("image/svg+xml"); - IOUtils.copy(Resources.getResource(getClass(), "measure-example.svg").openStream(), response.stream().output()); + response.stream().setMediaType(SVG); + String projectKey = request.mandatoryParam(PARAM_PROJECT); + String branch = request.param(PARAM_BRANCH); + String metricKey = request.mandatoryParam(PARAM_METRIC); + try (DbSession dbSession = dbClient.openSession(false)) { + ComponentDto project = componentFinder.getByKeyAndOptionalBranch(dbSession, projectKey, branch); + userSession.checkComponentPermission(USER, project); + MetricDto metric = dbClient.metricDao().selectByKey(dbSession, metricKey); + checkState(metric != null && metric.isEnabled(), "Metric '%s' hasn't been found", metricKey); + LiveMeasureDto measure = getMeasure(dbSession, project, metricKey); + write(generateSvg(metric, measure), response.stream().output(), UTF_8); + } catch (ProjectBadgesException | ForbiddenException | NotFoundException e) { + write(svgGenerator.generateError(e.getMessage()), response.stream().output(), UTF_8); + } + } + + private LiveMeasureDto getMeasure(DbSession dbSession, ComponentDto project, String metricKey) { + return dbClient.liveMeasureDao().selectMeasure(dbSession, project.uuid(), metricKey) + .orElseThrow(() -> new ProjectBadgesException(format("Measure '%s' has not been found for project '%s' and branch '%s'", metricKey, project.getKey(), project.getBranch()))); + } + + private String generateSvg(MetricDto metric, LiveMeasureDto measure) { + String metricType = metric.getValueType(); + switch (ValueType.valueOf(metricType)) { + case INT: + return generateBadge(metric, formatNumeric(getNonNullValue(measure, LiveMeasureDto::getValue).longValue()), Color.DEFAULT); + case PERCENT: + return generateBadge(metric, formatPercent(getNonNullValue(measure, LiveMeasureDto::getValue)), Color.DEFAULT); + case LEVEL: + return generateQualityGate(metric, measure); + case WORK_DUR: + return generateBadge(metric, formatDuration(getNonNullValue(measure, LiveMeasureDto::getValue).longValue()), Color.DEFAULT); + case RATING: + return generateRating(metric, measure); + default: + throw new IllegalStateException(format("Invalid metric type '%s'", metricType)); + } + } + + private String generateQualityGate(MetricDto metric, LiveMeasureDto measure) { + Level qualityGate = Level.valueOf(getNonNullValue(measure, LiveMeasureDto::getTextValue)); + return generateBadge(metric, QUALITY_GATE_MESSAGE_BY_STATUS.get(qualityGate), COLOR_BY_QUALITY_GATE_STATUS.get(qualityGate)); + } + + private String generateRating(MetricDto metric, LiveMeasureDto measure) { + Rating rating = valueOf(getNonNullValue(measure, LiveMeasureDto::getValue).intValue()); + return generateBadge(metric, rating.name(), COLOR_BY_RATING.get(rating)); + } + + private String generateBadge(MetricDto metric, String value, Color color) { + return svgGenerator.generateBadge(METRIC_NAME_BY_KEY.get(metric.getKey()), value, color); + } + + private static PARAM getNonNullValue(LiveMeasureDto measure, Function function) { + PARAM value = function.apply(measure); + checkState(value != null, "Measure not found"); + return value; } } diff --git a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesException.java b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesException.java new file mode 100644 index 00000000000..4520ebda792 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesException.java @@ -0,0 +1,28 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.badge.ws; + +class ProjectBadgesException extends RuntimeException { + + ProjectBadgesException(String message) { + super(message); + } + +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesWsModule.java b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesWsModule.java index 085fdc36291..98ffb09603b 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesWsModule.java +++ b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/ProjectBadgesWsModule.java @@ -39,7 +39,7 @@ public class ProjectBadgesWsModule extends Module { add( ProjectBadgesWs.class, QualityGateAction.class, - MeasureAction.class - ); + MeasureAction.class, + SvgGenerator.class); } } diff --git a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/SvgFormatter.java b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/SvgFormatter.java new file mode 100644 index 00000000000..e75911dd9cb --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/SvgFormatter.java @@ -0,0 +1,83 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.badge.ws; + +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Locale; + +import static java.lang.String.format; +import static org.apache.commons.lang.StringUtils.trim; + +class SvgFormatter { + + private static final String ZERO = "0"; + + private static final NumberFormat NUMERIC_FORMATTER = DecimalFormat.getInstance(Locale.ENGLISH); + private static final String NUMERIC_SUFFIX_LIST = " kmbt"; + private static final String NUMERIC_REGEXP = "\\.[0-9]+"; + + private static final DecimalFormat PERCENT_FORMATTER = new DecimalFormat("#.#"); + + private static final String DURATION_MINUTES_FORMAT = "%smin"; + private static final String DURATION_HOURS_FORMAT = "%sh"; + private static final String DURATION_DAYS_FORMAT = "%sd"; + private static final int DURATION_HOURS_IN_DAY = 8; + private static final double DURATION_ALMOST_ONE = 0.9; + private static final int DURATION_OF_ONE_HOUR_IN_MINUTES = 60; + + private SvgFormatter() { + // Only static methods + } + + static String formatNumeric(long value) { + if (value == 0) { + return ZERO; + } + NUMERIC_FORMATTER.setMaximumFractionDigits(1); + int power = (int) StrictMath.log10(value); + double valueToFormat = value / (Math.pow(10, Math.floorDiv(power, 3) * 3d)); + String formattedNumber = NUMERIC_FORMATTER.format(valueToFormat); + formattedNumber = formattedNumber + NUMERIC_SUFFIX_LIST.charAt(power / 3); + return formattedNumber.length() > 4 ? trim(formattedNumber.replaceAll(NUMERIC_REGEXP, "")) : trim(formattedNumber); + } + + static String formatPercent(double value) { + return PERCENT_FORMATTER.format(value) + "%"; + } + + static String formatDuration(long durationInMinutes) { + if (durationInMinutes == 0) { + return ZERO; + } + double days = (double) durationInMinutes / DURATION_HOURS_IN_DAY / DURATION_OF_ONE_HOUR_IN_MINUTES; + if (days > DURATION_ALMOST_ONE) { + return format(DURATION_DAYS_FORMAT, Math.round(days)); + } + double remainingDuration = durationInMinutes - (Math.floor(days) * DURATION_HOURS_IN_DAY * DURATION_OF_ONE_HOUR_IN_MINUTES); + double hours = remainingDuration / DURATION_OF_ONE_HOUR_IN_MINUTES; + if (hours > DURATION_ALMOST_ONE) { + return format(DURATION_HOURS_FORMAT, Math.round(hours)); + } + double minutes = remainingDuration - (Math.floor(hours) * DURATION_OF_ONE_HOUR_IN_MINUTES); + return format(DURATION_MINUTES_FORMAT, Math.round(minutes)); + } + +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/badge/ws/SvgGenerator.java b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/SvgGenerator.java new file mode 100644 index 00000000000..8bf78cd5a8b --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/badge/ws/SvgGenerator.java @@ -0,0 +1,119 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.badge.ws; + +import com.google.common.collect.ImmutableMap; +import java.awt.Font; +import java.awt.font.FontRenderContext; +import java.awt.geom.AffineTransform; +import java.io.IOException; +import java.util.Map; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.text.StrSubstitutor; +import org.sonar.api.server.ServerSide; + +import static java.lang.String.valueOf; +import static java.nio.charset.StandardCharsets.UTF_8; + +@ServerSide +public class SvgGenerator { + + private static final FontRenderContext FONT_RENDER_CONTEXT = new FontRenderContext(new AffineTransform(), true, true); + private static final Font FONT = new Font("Verdana", Font.PLAIN, 11); + + private static final int MARGIN = 6; + + private static final String PARAMETER_MARGIN = "margin"; + private static final String PARAMETER_TOTAL_WIDTH = "totalWidth"; + private static final String PARAMETER_LABEL_WIDTH = "labelWidth"; + private static final String PARAMETER_LABEL_WIDTH_PLUS_MARGIN = "LabelWidthPlusMargin"; + private static final String PARAMETER_VALUE_WIDTH = "valueWidth"; + private static final String PARAMETER_COLOR = "color"; + private static final String PARAMETER_LABEL = "label"; + private static final String PARAMETER_VALUE = "value"; + + private final String errorTemplate; + private final String badgeTemplate; + + public SvgGenerator() { + this.errorTemplate = readTemplate("error.svg"); + this.badgeTemplate = readTemplate("badge.svg"); + } + + public String generateBadge(String label, String value, Color backgroundValueColor) { + int labelWidth = computeWidth(label); + int valueWidth = computeWidth(value); + + Map values = ImmutableMap.builder() + .put(PARAMETER_MARGIN, valueOf(MARGIN)) + .put(PARAMETER_TOTAL_WIDTH, valueOf(MARGIN * 4 + labelWidth + valueWidth)) + .put(PARAMETER_LABEL_WIDTH, valueOf(MARGIN * 2 + labelWidth)) + .put(PARAMETER_LABEL_WIDTH_PLUS_MARGIN, valueOf(MARGIN * 3 + labelWidth)) + .put(PARAMETER_VALUE_WIDTH, valueOf(MARGIN * 2 + valueWidth)) + .put(PARAMETER_COLOR, backgroundValueColor.getValue()) + .put(PARAMETER_LABEL, label) + .put(PARAMETER_VALUE, value) + .build(); + StrSubstitutor strSubstitutor = new StrSubstitutor(values); + return strSubstitutor.replace(badgeTemplate); + } + + public String generateError(String error) { + Map values = ImmutableMap.of( + PARAMETER_TOTAL_WIDTH, valueOf(MARGIN + computeWidth(error) + MARGIN), + PARAMETER_LABEL, error); + StrSubstitutor strSubstitutor = new StrSubstitutor(values); + return strSubstitutor.replace(errorTemplate); + } + + private static int computeWidth(String text) { + return (int) FONT.getStringBounds(text, FONT_RENDER_CONTEXT).getWidth(); + } + + private String readTemplate(String template) { + try { + return IOUtils.toString(getClass().getResource("templates/" + template), UTF_8); + } catch (IOException e) { + throw new IllegalStateException(String.format("Can't read svg template '%s'", template), e); + } + } + + static class Color { + static final Color DEFAULT = new Color("#999"); + static final Color QUALITY_GATE_OK = new Color("#4c1"); + static final Color QUALITY_GATE_WARN = new Color("#ed7d20"); + static final Color QUALITY_GATE_ERROR = new Color("#d4333f"); + static final Color RATING_A = new Color("#00aa00"); + static final Color RATING_B = new Color("#b0d513"); + static final Color RATING_C = new Color("#eabe06"); + static final Color RATING_D = new Color("#ed7d20"); + static final Color RATING_E = new Color("#e00"); + + private final String value; + + private Color(String value) { + this.value = value; + } + + String getValue() { + return value; + } + } +} diff --git a/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/measure-example.svg b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/measure-example.svg index 8934ac3cfc2..3c1c4f97ecf 100644 --- a/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/measure-example.svg +++ b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/measure-example.svg @@ -1,22 +1,20 @@ - - - + + - - - - - - - + + + + + + + - - Coverage - Coverage - 100.0% - 100.0% + + quality gate + quality gate + passed + passed \ No newline at end of file diff --git a/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/quality_gate-example.svg b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/quality_gate-example.svg index 038231f8b52..06cba700093 100644 --- a/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/quality_gate-example.svg +++ b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/quality_gate-example.svg @@ -1,22 +1,115 @@ - - - - - - - - - - - - - - - - Quality Gate - Quality Gate - failing - failing - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/templates/badge.svg b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/templates/badge.svg new file mode 100644 index 00000000000..672699b421f --- /dev/null +++ b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/templates/badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + ${label} + ${label} + ${value} + ${value} + + \ No newline at end of file diff --git a/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/templates/error.svg b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/templates/error.svg new file mode 100644 index 00000000000..c9a7d35010d --- /dev/null +++ b/server/sonar-server/src/main/resources/org/sonar/server/badge/ws/templates/error.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + ${label} + ${label} + + \ No newline at end of file diff --git a/server/sonar-server/src/test/java/org/sonar/server/badge/ws/MeasureActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/badge/ws/MeasureActionTest.java index 51b619e5f92..0f7a82d7c52 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/badge/ws/MeasureActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/badge/ws/MeasureActionTest.java @@ -19,17 +19,258 @@ */ package org.sonar.server.badge.ws; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.sonar.api.measures.CoreMetrics; +import org.sonar.api.measures.Metric.Level; import org.sonar.api.server.ws.WebService; import org.sonar.api.server.ws.WebService.Param; +import org.sonar.api.web.UserRole; +import org.sonar.db.DbTester; +import org.sonar.db.component.BranchType; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.metric.MetricDto; +import org.sonar.server.badge.ws.SvgGenerator.Color; +import org.sonar.server.component.ComponentFinder; +import org.sonar.server.computation.task.projectanalysis.qualitymodel.Rating; +import org.sonar.server.tester.UserSessionRule; import org.sonar.server.ws.WsActionTester; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; +import static org.sonar.api.measures.CoreMetrics.BUGS_KEY; +import static org.sonar.api.measures.CoreMetrics.COVERAGE_KEY; +import static org.sonar.api.measures.CoreMetrics.SQALE_RATING_KEY; +import static org.sonar.api.measures.CoreMetrics.TECHNICAL_DEBT_KEY; +import static org.sonar.api.measures.Metric.Level.ERROR; +import static org.sonar.api.measures.Metric.Level.OK; +import static org.sonar.api.measures.Metric.Level.WARN; +import static org.sonar.api.measures.Metric.ValueType.INT; +import static org.sonar.api.measures.Metric.ValueType.LEVEL; +import static org.sonar.api.measures.Metric.ValueType.PERCENT; +import static org.sonar.api.measures.Metric.ValueType.RATING; +import static org.sonar.api.measures.Metric.ValueType.WORK_DUR; +import static org.sonar.server.badge.ws.SvgGenerator.Color.DEFAULT; +import static org.sonar.server.badge.ws.SvgGenerator.Color.QUALITY_GATE_ERROR; +import static org.sonar.server.badge.ws.SvgGenerator.Color.QUALITY_GATE_OK; +import static org.sonar.server.badge.ws.SvgGenerator.Color.QUALITY_GATE_WARN; +@RunWith(DataProviderRunner.class) public class MeasureActionTest { - private WsActionTester ws = new WsActionTester(new MeasureAction()); + @Rule + public ExpectedException expectedException = ExpectedException.none(); + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public DbTester db = DbTester.create(); + + private WsActionTester ws = new WsActionTester( + new MeasureAction(userSession, db.getDbClient(), new ComponentFinder(db.getDbClient(), null), new SvgGenerator())); + + @Test + public void int_measure() { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(BUGS_KEY).setValueType(INT.name())); + db.measures().insertLiveMeasure(project, metric, m -> m.setValue(10_000d)); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkSvg(response, "bugs", "10k", DEFAULT); + } + + @Test + public void percent_measure() { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(COVERAGE_KEY).setValueType(PERCENT.name())); + db.measures().insertLiveMeasure(project, metric, m -> m.setValue(12.345d)); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkSvg(response, "coverage", "12.3%", DEFAULT); + } + + @Test + public void duration_measure() { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(TECHNICAL_DEBT_KEY).setValueType(WORK_DUR.name())); + db.measures().insertLiveMeasure(project, metric, m -> m.setValue(10_000d)); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkSvg(response, "technical debt", "21d", DEFAULT); + } + + @DataProvider + public static Object[][] ratings() { + return new Object[][] { + {Rating.A, Color.RATING_A}, + {Rating.B, Color.RATING_B}, + {Rating.C, Color.RATING_C}, + {Rating.D, Color.RATING_D}, + {Rating.E, Color.RATING_E} + }; + } + + @Test + @UseDataProvider("ratings") + public void rating_measure(Rating rating, Color color) { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(SQALE_RATING_KEY).setValueType(RATING.name())); + db.measures().insertLiveMeasure(project, metric, m -> m.setValue((double) rating.getIndex()).setData(rating.name())); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkSvg(response, "maintainability", rating.name(), color); + } + + @DataProvider + public static Object[][] qualityGates() { + return new Object[][] { + {OK, "passed", QUALITY_GATE_OK}, + {WARN, "warning", QUALITY_GATE_WARN}, + {ERROR, "failed", QUALITY_GATE_ERROR} + }; + } + + @Test + @UseDataProvider("qualityGates") + public void quality_gate(Level status, String expectedValue, Color expectedColor) { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = createQualityGateMetric(); + db.measures().insertLiveMeasure(project, metric, m -> m.setData(status.name())); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkSvg(response, "quality gate", expectedValue, expectedColor); + } + + @Test + public void fail_on_invalid_quality_gate() { + ComponentDto project = db.components().insertMainBranch(); + userSession.addProjectPermission(UserRole.USER, project); + MetricDto metric = createQualityGateMetric(); + db.measures().insertLiveMeasure(project, metric, m -> m.setData("UNKNOWN")); + + expectedException.expect(IllegalArgumentException.class); + expectedException.expectMessage("No enum constant org.sonar.api.measures.Metric.Level.UNKNOWN"); + + ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute(); + } + + @Test + public void fail_when_measure_value_is_null() { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(BUGS_KEY).setValueType(INT.name())); + db.measures().insertLiveMeasure(project, metric, m -> m.setValue(null)); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Measure not found"); + + ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute(); + } + + @Test + public void project_does_not_exist() { + MetricDto metric = db.measures().insertMetric(m -> m.setKey(BUGS_KEY)); + + String response = ws.newRequest() + .setParam("project", "unknown") + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkError(response, "Component key 'unknown' not found"); + } + + @Test + public void branch_does_not_exist() { + ComponentDto project = db.components().insertMainBranch(); + ComponentDto branch = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.LONG)); + userSession.addProjectPermission(UserRole.USER, project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(BUGS_KEY)); + db.measures().insertLiveMeasure(project, metric, m -> m.setValue(10d)); + + String response = ws.newRequest() + .setParam("project", branch.getKey()) + .setParam("branch", "unknown") + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkError(response, String.format("Component '%s' on branch 'unknown' not found", branch.getKey())); + } + + @Test + public void measure_not_found() { + ComponentDto project = db.components().insertPublicProject(); + userSession.registerComponents(project); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(BUGS_KEY)); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkError(response, String.format("Measure '%s' has not been found for project '%s' and branch 'null'", metric.getKey(), project.getKey())); + } + + @Test + public void unauthorized() { + ComponentDto project = db.components().insertPrivateProject(); + MetricDto metric = db.measures().insertMetric(m -> m.setKey(BUGS_KEY)); + + String response = ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", metric.getKey()) + .execute().getInput(); + + checkError(response, "Insufficient privileges"); + } + + @Test + public void fail_when_metric_not_found() { + ComponentDto project = db.components().insertMainBranch(); + userSession.addProjectPermission(UserRole.USER, project); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Metric 'bugs' hasn't been found"); + + ws.newRequest() + .setParam("project", project.getKey()) + .setParam("metric", BUGS_KEY) + .execute(); + } @Test public void test_definition() { @@ -37,20 +278,29 @@ public class MeasureActionTest { assertThat(def.key()).isEqualTo("measure"); assertThat(def.isInternal()).isFalse(); assertThat(def.isPost()).isFalse(); - assertThat(def.since()).isNull(); + assertThat(def.since()).isEqualTo("7.1"); assertThat(def.responseExampleAsString()).isNotEmpty(); assertThat(def.params()) .extracting(Param::key, Param::isRequired) .containsExactlyInAnyOrder( - tuple("component", true), + tuple("project", true), + tuple("branch", false), tuple("metric", true)); } - @Test - public void test_example() { - String response = ws.newRequest().execute().getInput(); + private void checkSvg(String svg, String expectedLabel, String expectedValue, Color expectedColorValue) { + assertThat(svg).contains( + "", + "", + "rect fill=\"" + expectedColorValue.getValue() + "\""); + } + + private void checkError(String svg, String expectedError) { + assertThat(svg).contains("" + expectedError + ""); + } - assertThat(response).isEqualTo(ws.getDef().responseExampleAsString()); + private MetricDto createQualityGateMetric() { + return db.measures().insertMetric(m -> m.setKey(CoreMetrics.ALERT_STATUS_KEY).setValueType(LEVEL.name())); } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/badge/ws/ProjectBadgesWsModuleTest.java b/server/sonar-server/src/test/java/org/sonar/server/badge/ws/ProjectBadgesWsModuleTest.java index 69574191e11..0b6f717b2c0 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/badge/ws/ProjectBadgesWsModuleTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/badge/ws/ProjectBadgesWsModuleTest.java @@ -38,7 +38,7 @@ public class ProjectBadgesWsModuleTest { underTest.configure(container); - assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 3); + assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 4); } @Test diff --git a/server/sonar-server/src/test/java/org/sonar/server/badge/ws/SvgFormatterTest.java b/server/sonar-server/src/test/java/org/sonar/server/badge/ws/SvgFormatterTest.java new file mode 100644 index 00000000000..16b9b37bd78 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/badge/ws/SvgFormatterTest.java @@ -0,0 +1,101 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.badge.ws; + +import org.junit.Test; +import org.sonar.test.TestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.sonar.server.badge.ws.SvgFormatter.formatDuration; +import static org.sonar.server.badge.ws.SvgFormatter.formatNumeric; +import static org.sonar.server.badge.ws.SvgFormatter.formatPercent; + +public class SvgFormatterTest { + + private static final int HOURS_IN_DAY = 8; + + private static final long ONE_MINUTE = 1L; + private static final long ONE_HOUR = ONE_MINUTE * 60; + private static final long ONE_DAY = HOURS_IN_DAY * ONE_HOUR; + + @Test + public void format_numeric() { + assertThat(formatNumeric(0L)).isEqualTo("0"); + + assertThat(formatNumeric(5L)).isEqualTo("5"); + assertThat(formatNumeric(950L)).isEqualTo("950"); + + assertThat(formatNumeric(1_000L)).isEqualTo("1k"); + assertThat(formatNumeric(1_010L)).isEqualTo("1k"); + assertThat(formatNumeric(1_100L)).isEqualTo("1.1k"); + assertThat(formatNumeric(1_690L)).isEqualTo("1.7k"); + assertThat(formatNumeric(950_000L)).isEqualTo("950k"); + + assertThat(formatNumeric(1_000_000L)).isEqualTo("1m"); + assertThat(formatNumeric(1_010_000L)).isEqualTo("1m"); + + assertThat(formatNumeric(1_000_000_000L)).isEqualTo("1b"); + + assertThat(formatNumeric(1_000_000_000_000L)).isEqualTo("1t"); + } + + @Test + public void format_percent() { + assertThat(formatPercent(0d)).isEqualTo("0%"); + assertThat(formatPercent(12.345)).isEqualTo("12.3%"); + assertThat(formatPercent(12.56)).isEqualTo("12.6%"); + } + + @Test + public void format_duration() { + assertThat(formatDuration(0)).isEqualTo("0"); + assertThat(formatDuration(ONE_DAY)).isEqualTo("1d"); + assertThat(formatDuration(ONE_HOUR)).isEqualTo("1h"); + assertThat(formatDuration(ONE_MINUTE)).isEqualTo("1min"); + + assertThat(formatDuration(5 * ONE_DAY)).isEqualTo("5d"); + assertThat(formatDuration(2 * ONE_HOUR)).isEqualTo("2h"); + assertThat(formatDuration(ONE_MINUTE)).isEqualTo("1min"); + + assertThat(formatDuration(5 * ONE_DAY + 3 * ONE_HOUR)).isEqualTo("5d"); + assertThat(formatDuration(3 * ONE_HOUR + 25 * ONE_MINUTE)).isEqualTo("3h"); + assertThat(formatDuration(5 * ONE_DAY + 3 * ONE_HOUR + 40 * ONE_MINUTE)).isEqualTo("5d"); + } + + @Test + public void format_duration_is_rounding_result() { + // When starting to add more than 4 hours, the result will be rounded to the next day (as 4 hour is a half day) + assertThat(formatDuration(5 * ONE_DAY + 4 * ONE_HOUR)).isEqualTo("6d"); + assertThat(formatDuration(5 * ONE_DAY + 5 * ONE_HOUR)).isEqualTo("6d"); + + // When starting to add more than 30 minutes, the result will be rounded to the next hour + assertThat(formatDuration(3 * ONE_HOUR + 30 * ONE_MINUTE)).isEqualTo("4h"); + assertThat(formatDuration(3 * ONE_HOUR + 40 * ONE_MINUTE)).isEqualTo("4h"); + + // When duration is close to next unit (0.9), the result is rounded to next unit + assertThat(formatDuration(7 * ONE_HOUR + 20 + ONE_MINUTE)).isEqualTo("1d"); + assertThat(formatDuration(55 * ONE_MINUTE)).isEqualTo("1h"); + } + + @Test + public void only_statics() { + assertThat(TestUtils.hasOnlyPrivateConstructors(SvgFormatter.class)).isTrue(); + } +} diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java b/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java index f69e2162ee8..800f84477d9 100644 --- a/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java +++ b/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java @@ -38,6 +38,7 @@ public final class MediaTypes { public static final String JAVASCRIPT = "application/javascript"; public static final String HTML = "text/html"; public static final String DEFAULT = "application/octet-stream"; + public static final String SVG = "image/svg+xml"; private static final Map MAP = new ImmutableMap.Builder() .put("js", JAVASCRIPT) @@ -59,7 +60,7 @@ public final class MediaTypes { .put("jpeg", "image/jpeg") .put("tiff", "image/tiff") .put("png", "image/png") - .put("svg", "image/svg+xml") + .put("svg", SVG) .put("ico", "image/x-icon") .put("txt", TXT) .put("csv", "text/csv")