/*
* SonarQube
* Copyright (C) 2009-2019 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.ce.task.projectanalysis.step;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import org.sonar.api.utils.DateUtils;
import org.sonar.api.utils.MessageException;
import org.sonar.api.utils.System2;
import org.sonar.api.utils.log.Logger;
import org.sonar.api.utils.log.Loggers;
import org.sonar.ce.task.projectanalysis.analysis.AnalysisMetadataHolder;
import org.sonar.ce.task.projectanalysis.analysis.Branch;
import org.sonar.ce.task.projectanalysis.component.Component;
import org.sonar.ce.task.projectanalysis.component.ConfigurationRepository;
import org.sonar.ce.task.projectanalysis.component.TreeRootHolder;
import org.sonar.ce.task.projectanalysis.period.Period;
import org.sonar.ce.task.projectanalysis.period.PeriodHolder;
import org.sonar.ce.task.projectanalysis.period.PeriodHolderImpl;
import org.sonar.ce.task.step.ComputationStep;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.component.BranchDto;
import org.sonar.db.component.BranchType;
import org.sonar.db.component.SnapshotDto;
import org.sonar.db.component.SnapshotQuery;
import org.sonar.db.event.EventDto;
import static com.google.common.base.Preconditions.checkState;
import static java.lang.String.format;
import static org.sonar.core.config.CorePropertyDefinitions.LEAK_PERIOD;
import static org.sonar.core.config.CorePropertyDefinitions.LEAK_PERIOD_MODE_DATE;
import static org.sonar.core.config.CorePropertyDefinitions.LEAK_PERIOD_MODE_DAYS;
import static org.sonar.core.config.CorePropertyDefinitions.LEAK_PERIOD_MODE_MANUAL_BASELINE;
import static org.sonar.core.config.CorePropertyDefinitions.LEAK_PERIOD_MODE_PREVIOUS_VERSION;
import static org.sonar.core.config.CorePropertyDefinitions.LEAK_PERIOD_MODE_VERSION;
import static org.sonar.db.component.SnapshotDto.STATUS_PROCESSED;
import static org.sonar.db.component.SnapshotQuery.SORT_FIELD.BY_DATE;
import static org.sonar.db.component.SnapshotQuery.SORT_ORDER.ASC;
/**
* Populates the {@link PeriodHolder}
*
* Here is how these periods are computed :
* - Read the period property ${@link org.sonar.core.config.CorePropertyDefinitions#LEAK_PERIOD}
* - Try to find the matching snapshots from the property
* - If a snapshot is found, a period is set to the repository, otherwise fail with MessageException
*/
public class LoadPeriodsStep implements ComputationStep {
private static final Logger LOG = Loggers.get(LoadPeriodsStep.class);
private final AnalysisMetadataHolder analysisMetadataHolder;
private final TreeRootHolder treeRootHolder;
private final PeriodHolderImpl periodsHolder;
private final System2 system2;
private final DbClient dbClient;
private final ConfigurationRepository configRepository;
public LoadPeriodsStep(AnalysisMetadataHolder analysisMetadataHolder, TreeRootHolder treeRootHolder, PeriodHolderImpl periodsHolder,
System2 system2, DbClient dbClient, ConfigurationRepository configRepository) {
this.analysisMetadataHolder = analysisMetadataHolder;
this.treeRootHolder = treeRootHolder;
this.periodsHolder = periodsHolder;
this.system2 = system2;
this.dbClient = dbClient;
this.configRepository = configRepository;
}
@Override
public String getDescription() {
return "Load new code period";
}
@Override
public void execute(ComputationStep.Context context) {
if (analysisMetadataHolder.isFirstAnalysis() || !analysisMetadataHolder.isLongLivingBranch()) {
periodsHolder.setPeriod(null);
return;
}
periodsHolder.setPeriod(resolvePeriod(treeRootHolder.getRoot()).orElse(null));
}
private Optional resolvePeriod(Component projectOrView) {
String currentVersion = projectOrView.getProjectAttributes().getProjectVersion();
Optional propertyValue = configRepository.getConfiguration().get(LEAK_PERIOD)
.filter(t -> !t.isEmpty());
checkPeriodProperty(propertyValue.isPresent(), "", "property is undefined or value is empty");
try (DbSession dbSession = dbClient.openSession(false)) {
Optional manualBaselineOpt = resolveByManualBaseline(dbSession, projectOrView.getUuid());
if (manualBaselineOpt.isPresent()) {
return manualBaselineOpt;
}
return resolve(dbSession, projectOrView.getUuid(), currentVersion, propertyValue.get());
}
}
private Optional resolveByManualBaseline(DbSession dbSession, String projectUuid) {
Branch branch = analysisMetadataHolder.getBranch();
if (branch.getType() != BranchType.LONG) {
return Optional.empty();
}
return dbClient.branchDao().selectByUuid(dbSession, projectUuid)
.map(branchDto -> resolveByManualBaseline(dbSession, projectUuid, branchDto));
}
private Period resolveByManualBaseline(DbSession dbSession, String projectUuid, BranchDto branchDto) {
String baselineAnalysisUuid = branchDto.getManualBaseline();
if (baselineAnalysisUuid == null) {
return null;
}
LOG.debug("Resolving new code period by manual baseline");
SnapshotDto baseline = dbClient.snapshotDao().selectByUuid(dbSession, baselineAnalysisUuid)
.filter(t -> t.getComponentUuid().equals(projectUuid))
.orElseThrow(() -> new IllegalStateException("Analysis '" + baselineAnalysisUuid + "' of project '" + projectUuid
+ "' defined as manual baseline does not exist"));
return newPeriod(LEAK_PERIOD_MODE_MANUAL_BASELINE, null, baseline);
}
private Optional resolve(DbSession dbSession, String projectUuid, String analysisProjectVersion, String propertyValue) {
Integer days = parseDaysQuietly(propertyValue);
if (days != null) {
return resolveByDays(dbSession, projectUuid, days, propertyValue);
}
Instant date = parseDate(propertyValue);
if (date != null) {
return resolveByDate(dbSession, projectUuid, date, propertyValue);
}
List versions = dbClient.eventDao().selectVersionsByMostRecentFirst(dbSession, projectUuid);
if (versions.isEmpty()) {
return resolveWhenNoExistingVersion(dbSession, projectUuid, analysisProjectVersion, propertyValue);
}
String mostRecentVersion = Optional.ofNullable(versions.iterator().next().getName())
.orElseThrow(() -> new IllegalStateException("selectVersionsByMostRecentFirst returned a DTO which didn't have a name"));
boolean previousVersionPeriod = LEAK_PERIOD_MODE_PREVIOUS_VERSION.equals(propertyValue);
if (previousVersionPeriod) {
if (versions.size() == 1) {
return resolvePreviousVersionWithOnlyOneExistingVersion(dbSession, projectUuid);
}
return resolvePreviousVersion(dbSession, analysisProjectVersion, versions, mostRecentVersion);
}
return resolveVersion(dbSession, versions, propertyValue);
}
@CheckForNull
private static Instant parseDate(String propertyValue) {
try {
LocalDate localDate = LocalDate.parse(propertyValue);
return localDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
} catch (DateTimeParseException e) {
boolean invalidDate = e.getCause() == null || e.getCause() == e || !e.getCause().getMessage().contains("Invalid date");
checkPeriodProperty(invalidDate, propertyValue, "Invalid date");
return null;
}
}
private Optional resolveByDays(DbSession dbSession, String projectUuid, Integer days, String propertyValue) {
checkPeriodProperty(days > 0, propertyValue, "number of days is <= 0");
long analysisDate = analysisMetadataHolder.getAnalysisDate();
List snapshots = dbClient.snapshotDao().selectAnalysesByQuery(dbSession, createCommonQuery(projectUuid).setCreatedBefore(analysisDate).setSort(BY_DATE, ASC));
ensureNotOnFirstAnalysis(!snapshots.isEmpty());
Instant targetDate = DateUtils.addDays(Instant.ofEpochMilli(analysisDate), -days);
LOG.debug("Resolving new code period by {} days: {}", days, supplierToString(() -> logDate(targetDate)));
SnapshotDto snapshot = findNearestSnapshotToTargetDate(snapshots, targetDate);
return Optional.of(newPeriod(LEAK_PERIOD_MODE_DAYS, String.valueOf((int) days), snapshot));
}
private Optional resolveByDate(DbSession dbSession, String projectUuid, Instant date, String propertyValue) {
Instant now = Instant.ofEpochMilli(system2.now());
checkPeriodProperty(date.compareTo(now) <= 0, propertyValue,
"date is in the future (now: '%s')", supplierToString(() -> logDate(now)));
LOG.debug("Resolving new code period by date: {}", supplierToString(() -> logDate(date)));
Optional period = findFirstSnapshot(dbSession, createCommonQuery(projectUuid).setCreatedAfter(date.toEpochMilli()).setSort(BY_DATE, ASC))
.map(dto -> newPeriod(LEAK_PERIOD_MODE_DATE, DateUtils.formatDate(date), dto));
checkPeriodProperty(period.isPresent(), propertyValue, "No analysis found created after date '%s'", supplierToString(() -> logDate(date)));
return period;
}
private Optional resolveWhenNoExistingVersion(DbSession dbSession, String projectUuid, String currentVersion, String propertyValue) {
LOG.debug("Resolving first analysis as new code period as there is no existing version");
boolean previousVersionPeriod = LEAK_PERIOD_MODE_PREVIOUS_VERSION.equals(propertyValue);
boolean currentVersionPeriod = currentVersion.equals(propertyValue);
checkPeriodProperty(previousVersionPeriod || currentVersionPeriod, propertyValue,
"No existing version. Property should be either '%s' or the current version '%s' (actual: '%s')",
LEAK_PERIOD_MODE_PREVIOUS_VERSION, currentVersion, propertyValue);
String periodMode = previousVersionPeriod ? LEAK_PERIOD_MODE_PREVIOUS_VERSION : LEAK_PERIOD_MODE_VERSION;
return findOldestAnalysis(dbSession, periodMode, projectUuid);
}
private Optional resolvePreviousVersionWithOnlyOneExistingVersion(DbSession dbSession, String projectUuid) {
LOG.debug("Resolving first analysis as new code period as there is only one existing version");
return findOldestAnalysis(dbSession, LEAK_PERIOD_MODE_PREVIOUS_VERSION, projectUuid);
}
private Optional findOldestAnalysis(DbSession dbSession, String periodMode, String projectUuid) {
Optional period = dbClient.snapshotDao().selectOldestSnapshot(dbSession, projectUuid)
.map(dto -> newPeriod(periodMode, null, dto));
ensureNotOnFirstAnalysis(period.isPresent());
return period;
}
private Optional resolvePreviousVersion(DbSession dbSession, String currentVersion, List versions, String mostRecentVersion) {
EventDto previousVersion = versions.get(currentVersion.equals(mostRecentVersion) ? 1 : 0);
LOG.debug("Resolving new code period by previous version: {}", previousVersion.getName());
return newPeriod(dbSession, LEAK_PERIOD_MODE_PREVIOUS_VERSION, previousVersion);
}
private Optional resolveVersion(DbSession dbSession, List versions, String propertyValue) {
LOG.debug("Resolving new code period by version: {}", propertyValue);
Optional version = versions.stream().filter(t -> propertyValue.equals(t.getName())).findFirst();
checkPeriodProperty(version.isPresent(), propertyValue,
"version is none of the existing ones: %s", supplierToString(() -> toVersions(versions)));
return newPeriod(dbSession, LEAK_PERIOD_MODE_VERSION, version.get());
}
private Optional newPeriod(DbSession dbSession, String periodMode, EventDto previousVersion) {
Optional period = dbClient.snapshotDao().selectByUuid(dbSession, previousVersion.getAnalysisUuid())
.map(dto -> newPeriod(periodMode, previousVersion.getName(), dto));
if (!period.isPresent()) {
throw new IllegalStateException(format("Analysis '%s' for version event '%s' has been deleted",
previousVersion.getAnalysisUuid(), previousVersion.getName()));
}
return period;
}
private static String toVersions(List versions) {
return Arrays.toString(versions.stream().map(EventDto::getName).toArray(String[]::new));
}
private static Object supplierToString(Supplier s) {
return new Object() {
@Override
public String toString() {
return s.get();
}
};
}
private static Period newPeriod(String mode, @Nullable String modeParameter, SnapshotDto dto) {
return new Period(mode, modeParameter, dto.getCreatedAt(), dto.getUuid());
}
private static void checkPeriodProperty(boolean test, String propertyValue, String testDescription, Object... args) {
if (!test) {
LOG.debug("Invalid code period '{}': {}", propertyValue, supplierToString(() -> format(testDescription, args)));
throw MessageException.of(format("Invalid new code period. '%s' is not one of: " +
"integer > 0, date before current analysis j, \"previous_version\", or version string that exists in the project' \n" +
"Please contact a project administrator to correct this setting", propertyValue));
}
}
private Optional findFirstSnapshot(DbSession session, SnapshotQuery query) {
return dbClient.snapshotDao().selectAnalysesByQuery(session, query)
.stream()
.findFirst();
}
private static void ensureNotOnFirstAnalysis(boolean expression) {
checkState(expression, "Attempting to resolve period while no analysis exist for project");
}
@CheckForNull
private static Integer parseDaysQuietly(String property) {
try {
return Integer.parseInt(property);
} catch (NumberFormatException e) {
// Nothing to, it means that the property is not a number of days
return null;
}
}
private static SnapshotDto findNearestSnapshotToTargetDate(List snapshots, Instant targetDate) {
// FIXME shouldn't this be the first analysis after targetDate?
Duration bestDuration = null;
SnapshotDto nearest = null;
for (SnapshotDto snapshot : snapshots) {
Instant createdAt = Instant.ofEpochMilli(snapshot.getCreatedAt());
Duration duration = Duration.between(targetDate, createdAt).abs();
if (bestDuration == null || duration.compareTo(bestDuration) <= 0) {
bestDuration = duration;
nearest = snapshot;
}
}
return nearest;
}
private static SnapshotQuery createCommonQuery(String projectUuid) {
return new SnapshotQuery().setComponentUuid(projectUuid).setStatus(STATUS_PROCESSED);
}
private static String logDate(Instant instant) {
return DateUtils.formatDate(instant.truncatedTo(ChronoUnit.SECONDS));
}
}