/* * Copyright (C) 2020 Michael Clarke * * 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 com.github.mc1arke.sonarqube.plugin.ce.pullrequest; import com.github.mc1arke.sonarqube.plugin.CommunityBranchPlugin; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Document; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.FormatterFactory; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Heading; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Image; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Link; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.ListItem; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Node; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Paragraph; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Text; import org.apache.commons.lang.StringUtils; import org.sonar.api.ce.posttask.Analysis; import org.sonar.api.ce.posttask.Project; import org.sonar.api.ce.posttask.QualityGate; import org.sonar.api.ce.posttask.QualityGate.EvaluationStatus; import org.sonar.api.ce.posttask.ScannerContext; import org.sonar.api.config.Configuration; import org.sonar.api.issue.Issue; import org.sonar.api.measures.CoreMetrics; import org.sonar.api.measures.Metric; import org.sonar.api.rules.RuleType; import org.sonar.ce.task.projectanalysis.component.Component; import org.sonar.ce.task.projectanalysis.component.TreeRootHolder; import org.sonar.ce.task.projectanalysis.measure.Measure; import org.sonar.ce.task.projectanalysis.measure.MeasureRepository; import org.sonar.ce.task.projectanalysis.metric.MetricRepository; import org.sonar.core.issue.DefaultIssue; import org.sonar.server.measure.Rating; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; public class AnalysisDetails { private static final List<String> CLOSED_ISSUE_STATUS = Arrays.asList(Issue.STATUS_CLOSED, Issue.STATUS_RESOLVED); private static final List<BigDecimal> COVERAGE_LEVELS = Arrays.asList(BigDecimal.valueOf(100), BigDecimal.valueOf(90), BigDecimal.valueOf(60), BigDecimal.valueOf(50), BigDecimal.valueOf(40), BigDecimal.valueOf(25)); private static final List<DuplicationMapping> DUPLICATION_LEVELS = Arrays.asList(new DuplicationMapping(BigDecimal.valueOf(3), "3"), new DuplicationMapping(BigDecimal.valueOf(5), "5"), new DuplicationMapping(BigDecimal.TEN, "10"), new DuplicationMapping(BigDecimal.valueOf(20), "20")); private final String publicRootURL; private final BranchDetails branchDetails; private final MeasuresHolder measuresHolder; private final PostAnalysisIssueVisitor postAnalysisIssueVisitor; private final QualityGate qualityGate; private final Analysis analysis; private final Project project; private final ScannerContext scannerContext; private final Configuration configuration; AnalysisDetails(BranchDetails branchDetails, PostAnalysisIssueVisitor postAnalysisIssueVisitor, QualityGate qualityGate, MeasuresHolder measuresHolder, Analysis analysis, Project project, Configuration configuration, String publicRootURL, ScannerContext scannerContext) { super(); this.publicRootURL = publicRootURL; this.branchDetails = branchDetails; this.measuresHolder = measuresHolder; this.postAnalysisIssueVisitor = postAnalysisIssueVisitor; this.qualityGate = qualityGate; this.analysis = analysis; this.project = project; this.scannerContext = scannerContext; this.configuration = configuration; } public String getBranchName() { return branchDetails.getBranchName(); } public String getCommitSha() { return branchDetails.getCommitId(); } public String getDashboardUrl() { return publicRootURL + "/dashboard?id=" + encode(project.getKey()) + "&pullRequest=" + branchDetails.getBranchName(); } public String getIssueUrl(String issueKey) { return publicRootURL + "/project/issues?id=" + encode(project.getKey()) + "&pullRequest=" + branchDetails.getBranchName() + "&issues=" + issueKey + "&open=" + issueKey; } public QualityGate.Status getQualityGateStatus() { return qualityGate.getStatus(); } public Optional<String> getScannerProperty(String propertyName) { return Optional.ofNullable(scannerContext.getProperties().get(propertyName)); } public String createAnalysisSummary(FormatterFactory formatterFactory) { BigDecimal newCoverage = getNewCoverage().orElse(null); double coverage = findMeasure(CoreMetrics.COVERAGE_KEY).map(Measure::getDoubleValue).orElse(0D); BigDecimal newDuplications = findQualityGateCondition(CoreMetrics.NEW_DUPLICATED_LINES_DENSITY_KEY) .filter(condition -> condition.getStatus() != EvaluationStatus.NO_VALUE) .map(QualityGate.Condition::getValue) .map(BigDecimal::new) .orElse(null); double duplications = findMeasure(CoreMetrics.DUPLICATED_LINES_DENSITY_KEY).map(Measure::getDoubleValue).orElse(0D); NumberFormat decimalFormat = new DecimalFormat("#0.00", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); Map<RuleType, Long> issueCounts = countRuleByType(); long issueTotal = issueCounts.values().stream().mapToLong(l -> l).sum(); List<QualityGate.Condition> failedConditions = findFailedConditions(); String baseImageUrl = getBaseImageUrl(); Document document = new Document(new Paragraph((QualityGate.Status.OK == getQualityGateStatus() ? new Image("Passed", baseImageUrl + "/checks/QualityGateBadge/passed.svg?sanitize=true") : new Image("Failed", baseImageUrl + "/checks/QualityGateBadge/failed.svg?sanitize=true"))), failedConditions.isEmpty() ? new Text("") : new com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.List( com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.List.Style.BULLET, failedConditions.stream().map(c -> new ListItem(new Text(format(c)))) .toArray(ListItem[]::new)), new Heading(1, new Text("Analysis Details")), new Heading(2, new Text( issueTotal + " Issue" + (issueCounts.values().stream().mapToLong(l -> l).sum() == 1 ? "" : "s"))), new com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.List( com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.List.Style.BULLET, new ListItem(new Image("Bug", baseImageUrl + "/common/bug.svg?sanitize=true"), new Text(" "), new Text( pluralOf(issueCounts.get(RuleType.BUG), "Bug", "Bugs"))), new ListItem(new Image("Vulnerability", baseImageUrl + "/common/vulnerability.svg?sanitize=true"), new Text(" "), new Text(pluralOf( issueCounts.get(RuleType.VULNERABILITY) + issueCounts.get(RuleType.SECURITY_HOTSPOT), "Vulnerability", "Vulnerabilities"))), new ListItem(new Image("Code Smell", baseImageUrl + "/common/vulnerability.svg?sanitize=true"), new Text(" "), new Text( pluralOf(issueCounts.get(RuleType.CODE_SMELL), "Code Smell", "Code Smells")))), new Heading(2, new Text("Coverage and Duplications")), new com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.List( com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.List.Style.BULLET, new ListItem(createCoverageImage(newCoverage, baseImageUrl), new Text(" "), new Text( Optional.ofNullable(newCoverage).map(decimalFormat::format) .map(i -> i + "% Coverage") .orElse("No coverage information") + " (" + decimalFormat.format(coverage) + "% Estimated after merge)")), new ListItem(createDuplicateImage(newDuplications, baseImageUrl), new Text(" "), new Text( Optional.ofNullable(newDuplications).map(decimalFormat::format) .map(i -> i + "% Duplicated Code") .orElse("No duplication information") + " (" + decimalFormat.format(duplications) + "% Estimated after merge)"))), new Link(getDashboardUrl(), new Text("View in SonarQube"))); return formatterFactory.documentFormatter().format(document, formatterFactory); } public String createAnalysisIssueSummary(PostAnalysisIssueVisitor.ComponentIssue componentIssue, FormatterFactory formatterFactory) { final DefaultIssue issue = componentIssue.getIssue(); String baseImageUrl = getBaseImageUrl(); Long effort = issue.effortInMinutes(); Node effortNode = (null == effort ? new Text("") : new Paragraph(new Text(String.format("**Duration (min):** %s", effort)))); String resolution = issue.resolution(); Node resolutionNode = (StringUtils.isBlank(resolution) ? new Text("") : new Paragraph(new Text(String.format("**Resolution:** %s ", resolution)))); Document document = new Document( new Paragraph(new Text(String.format("**Type:** %s ", issue.type().name())), new Image(issue.type().name(), String.format("%s/checks/IssueType/%s.svg?sanitize=true", baseImageUrl, issue.type().name().toLowerCase()))), new Paragraph(new Text(String.format("**Severity:** %s ", issue.severity())), new Image(issue.severity(), String.format("%s/checks/Severity/%s.svg?sanitize=true", baseImageUrl, issue.severity().toLowerCase()))), new Paragraph(new Text(String.format("**Message:** %s", issue.getMessage()))), effortNode, resolutionNode, new Link(getIssueUrl(issue.key()), new Text("View in SonarQube")) ); return formatterFactory.documentFormatter().format(document, formatterFactory); } public String getBaseImageUrl() { return configuration.get(CommunityBranchPlugin.IMAGE_URL_BASE) .orElse(publicRootURL + "/static/communityBranchPlugin") .replaceAll("/*$", ""); } public Optional<String> getSCMPathForIssue(PostAnalysisIssueVisitor.ComponentIssue componentIssue) { Component component = componentIssue.getComponent(); if (Component.Type.FILE.equals(component.getType())) { return component.getReportAttributes().getScmPath(); } return Optional.empty(); } public PostAnalysisIssueVisitor getPostAnalysisIssueVisitor() { return postAnalysisIssueVisitor; } private static String encode(String original) { try { return URLEncoder.encode(original, StandardCharsets.UTF_8.name()); } catch (UnsupportedEncodingException e) { throw new IllegalStateException("Standard charset not found in JVM", e); } } private static Image createCoverageImage(BigDecimal coverage, String baseImageUrl) { if (null == coverage) { return new Image("No coverage information", baseImageUrl + "/checks/CoverageChart/NoCoverageInfo.svg?sanitize=true"); } BigDecimal matchedLevel = BigDecimal.ZERO; for (BigDecimal level : COVERAGE_LEVELS) { if (coverage.compareTo(level) >= 0) { matchedLevel = level; break; } } return new Image(matchedLevel + " percent coverage", baseImageUrl + "/checks/CoverageChart/" + matchedLevel + ".svg?sanitize=true"); } private static Image createDuplicateImage(BigDecimal duplications, String baseImageUrl) { if (null == duplications) { return new Image("No duplication information", baseImageUrl + "/checks/Duplications/NoDuplicationInfo.svg?sanitize=true"); } String matchedLevel = "20plus"; for (DuplicationMapping level : DUPLICATION_LEVELS) { if (level.getDuplicationLevel().compareTo(duplications) >= 0) { matchedLevel = level.getImageName(); break; } } return new Image(matchedLevel + " percent duplication", baseImageUrl + "/checks/Duplications/" + matchedLevel + ".svg?sanitize=true"); } public Date getAnalysisDate() { return analysis.getDate(); } public String getAnalysisId() { return analysis.getAnalysisUuid(); } public String getAnalysisProjectKey() { return project.getKey(); } public List<QualityGate.Condition> findFailedConditions() { return qualityGate.getConditions().stream().filter(c -> c.getStatus() == QualityGate.EvaluationStatus.ERROR) .collect(Collectors.toList()); } public Optional<Measure> findMeasure(String metricKey) { return measuresHolder.getMeasureRepository().getRawMeasure(measuresHolder.getTreeRootHolder().getRoot(), measuresHolder.getMetricRepository() .getByKey(metricKey)); } public Optional<QualityGate.Condition> findQualityGateCondition(String metricKey) { return qualityGate.getConditions().stream().filter(c -> metricKey.equals(c.getMetricKey())).findFirst(); } public Map<RuleType, Long> countRuleByType() { return Arrays.stream(RuleType.values()).collect(Collectors.toMap(k -> k, k -> postAnalysisIssueVisitor.getIssues() .stream() .map(PostAnalysisIssueVisitor.ComponentIssue::getIssue) .filter(i -> !CLOSED_ISSUE_STATUS .contains(i.status())) .filter(i -> k == i.type()).count())); } private static String pluralOf(long value, String singleLabel, String multiLabel) { return value + " " + (1 == value ? singleLabel : multiLabel); } public static String format(QualityGate.Condition condition) { Metric<?> metric = CoreMetrics.getMetric(condition.getMetricKey()); if (metric.getType() == Metric.ValueType.RATING) { return String .format("%s %s (%s %s)", Rating.valueOf(Integer.parseInt(condition.getValue())), metric.getName(), condition.getOperator() == QualityGate.Operator.GREATER_THAN ? "is worse than" : "is better than", Rating.valueOf(Integer.parseInt(condition.getErrorThreshold()))); } else if (metric.getType() == Metric.ValueType.PERCENT) { NumberFormat numberFormat = new DecimalFormat("#0.00", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); return String.format("%s%% %s (%s %s%%)", numberFormat.format(new BigDecimal(condition.getValue())), metric.getName(), condition.getOperator() == QualityGate.Operator.GREATER_THAN ? "is greater than" : "is less than", numberFormat.format(new BigDecimal(condition.getErrorThreshold()))); } else { return String.format("%s %s (%s %s)", condition.getValue(), metric.getName(), condition.getOperator() == QualityGate.Operator.GREATER_THAN ? "is greater than" : "is less than", condition.getErrorThreshold()); } } public Optional<BigDecimal> getNewCoverage(){ return findQualityGateCondition(CoreMetrics.NEW_COVERAGE_KEY) .filter(condition -> condition.getStatus() != EvaluationStatus.NO_VALUE) .map(QualityGate.Condition::getValue) .map(BigDecimal::new); } public static class BranchDetails { private final String branchName; private final String commitId; BranchDetails(String branchName, String commitId) { this.branchName = branchName; this.commitId = commitId; } public String getBranchName() { return branchName; } public String getCommitId() { return commitId; } } public static class MeasuresHolder { private final MetricRepository metricRepository; private final MeasureRepository measureRepository; private final TreeRootHolder treeRootHolder; MeasuresHolder(MetricRepository metricRepository, MeasureRepository measureRepository, TreeRootHolder treeRootHolder) { this.metricRepository = metricRepository; this.measureRepository = measureRepository; this.treeRootHolder = treeRootHolder; } public MetricRepository getMetricRepository() { return metricRepository; } public MeasureRepository getMeasureRepository() { return measureRepository; } public TreeRootHolder getTreeRootHolder() { return treeRootHolder; } } private static class DuplicationMapping { private final BigDecimal duplicationLevel; private final String imageName; DuplicationMapping(BigDecimal duplicationLevel, String imageName) { this.duplicationLevel = duplicationLevel; this.imageName = imageName; } private BigDecimal getDuplicationLevel() { return duplicationLevel; } private String getImageName() { return imageName; } } }