From ed45e8cd08ff825653f3224618e67ba60001803c Mon Sep 17 00:00:00 2001 From: Cedric Champeau Date: Tue, 16 Jul 2024 19:05:43 +0200 Subject: [PATCH] Rework the version catalog update task This commit completely reworks the version catalog update task. The previous implementation had issues because there was no shared state between passes, so it was impossible to make smarter decisions based on something seen previously. It also appears that Gradle may, or may not, send all candidates, and the reasons are unclear. This commit changes the algorithm to be stateful instead. It also makes the resolution of metadata parallel, so that the builds are faster. The algorithm makes sure that candidate versions are passed in order, from the latest release to the oldest one. Subclasses may decide to reject a candidate, in which case the next one will be tested, or they may accept it as a fallback, in which case all versions can potentially be tested. If a version is accepted, then the loop is interrupted and no other candidate is tested. --- gradle.properties | 2 +- .../catalogs/tasks/VersionCatalogUpdate.java | 445 +++++++++++------- .../build/compat/FindBaselineTask.java | 8 +- .../compat/MavenMetadataVersionHelper.java | 36 +- .../micronaut/build/compat/VersionModel.java | 82 ---- .../build/utils/ComparableVersion.java | 153 ++++++ .../io/micronaut/build/utils/Downloader.java | 38 ++ .../build/utils/ExternalURLService.java | 20 +- .../micronaut/build/utils/VersionParser.java | 69 +++ .../tasks/VersionCatalogUpdateTest.groovy | 46 +- .../catalogs/tasks/VersionParserTest.groovy | 72 +++ .../MavenMetadataVersionParserTest.groovy | 10 +- .../build/compat/VersionModelTest.groovy | 23 - 13 files changed, 644 insertions(+), 360 deletions(-) delete mode 100644 src/main/java/io/micronaut/build/compat/VersionModel.java create mode 100644 src/main/java/io/micronaut/build/utils/ComparableVersion.java create mode 100644 src/main/java/io/micronaut/build/utils/Downloader.java create mode 100644 src/main/java/io/micronaut/build/utils/VersionParser.java create mode 100644 src/test/groovy/io/micronaut/build/catalogs/tasks/VersionParserTest.groovy delete mode 100644 src/test/groovy/io/micronaut/build/compat/VersionModelTest.groovy diff --git a/gradle.properties b/gradle.properties index b91bb270..d29bbb9f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -projectVersion=7.1.5-SNAPSHOT +projectVersion=7.2.0-SNAPSHOT title=Micronaut Build Plugins projectDesc=Micronaut internal Gradle plugins. Not intended to be used in user's projects projectUrl=https://micronaut.io diff --git a/src/main/java/io/micronaut/build/catalogs/tasks/VersionCatalogUpdate.java b/src/main/java/io/micronaut/build/catalogs/tasks/VersionCatalogUpdate.java index 7c1f9cbf..82f3a04a 100644 --- a/src/main/java/io/micronaut/build/catalogs/tasks/VersionCatalogUpdate.java +++ b/src/main/java/io/micronaut/build/catalogs/tasks/VersionCatalogUpdate.java @@ -21,18 +21,16 @@ import io.micronaut.build.catalogs.internal.Status; import io.micronaut.build.catalogs.internal.VersionCatalogTomlModel; import io.micronaut.build.catalogs.internal.VersionModel; +import io.micronaut.build.compat.MavenMetadataVersionHelper; +import io.micronaut.build.utils.ComparableVersion; +import io.micronaut.build.utils.Downloader; +import io.micronaut.build.utils.VersionParser; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; -import org.gradle.api.artifacts.ComponentSelection; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.ConfigurationContainer; -import org.gradle.api.artifacts.Dependency; -import org.gradle.api.artifacts.ExternalModuleDependency; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.component.ModuleComponentIdentifier; import org.gradle.api.artifacts.dsl.DependencyHandler; -import org.gradle.api.artifacts.result.ResolutionResult; -import org.gradle.api.artifacts.result.UnresolvedDependencyResult; +import org.gradle.api.artifacts.repositories.MavenArtifactRepository; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.MapProperty; @@ -51,16 +49,22 @@ import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import static java.util.Collections.reverseOrder; + /** * A task which updates version catalog files and outputs a copy * of them. @@ -90,15 +94,21 @@ public abstract class VersionCatalogUpdate extends DefaultTask { @Internal public abstract Property getFailWhenNoVersionFound(); - /** - * Subclasses may implement this method to add custom rejection logic - * without breaking up-to-date checking - * @param currentVersion the current version of a module, as found in the catalog - * @param candidateVersion the candidate version of a module, as found in a remote repository - * @return true if the candidate version should be excluded - */ - protected boolean shouldIgnoreVersion(String currentVersion, String candidateVersion) { - return false; + @Input + public abstract ListProperty getRepositoryBaseUris(); + + public VersionCatalogUpdate() { + getRepositoryBaseUris().convention( + getProject().getRepositories().stream() + .filter(MavenArtifactRepository.class::isInstance) + .map(MavenArtifactRepository.class::cast) + .map(MavenArtifactRepository::getUrl) + .toList() + ); + } + + protected void processCandidate(CandidateDetails details) { + } @TaskAction @@ -148,131 +158,79 @@ private void updateCatalog(File inputCatalog, File outputCatalog, File logFile) detachedConfiguration.setTransitive(false); detachedConfiguration.getResolutionStrategy() .cacheDynamicVersionsFor(0, TimeUnit.MINUTES); - List rejectedQualifiers = getRejectedQualifiers().get() + var rejectedQualifiers = getRejectedQualifiers().get() .stream() .map(qualifier -> Pattern.compile("(?i).*[.-]" + qualifier + "[.\\d-+]*")) .toList(); var rejectedVersionsPerModule = getRejectedVersionsPerModule().get(); - detachedConfiguration.getResolutionStrategy().getComponentSelection().all(rules -> { - ModuleComponentIdentifier candidateModule = rules.getCandidate(); - model.findLibrary( - candidateModule.getGroup(), candidateModule.getModule() - ).ifPresent(library -> { - VersionModel version = library.getVersion(); - if (version.getReference() != null) { - version = model.findVersion(version.getReference()).orElse(null); - } - if (version != null) { - String required = version.getVersion().getRequire(); - if (required != null) { - var candidateVersion = candidateModule.getVersion(); - if (shouldIgnoreVersion(required, candidateVersion)) { - rules.reject("Rejecting version " + candidateVersion + " because of configuration. It was rejected by custom logic"); - log.println("Rejecting version " + candidateVersion + " because of configuration. It was rejected by custom logic"); - return; - } - var moduleIdentifier = candidateModule.getModuleIdentifier(); - rejectedQualifiers.forEach(qualifier -> { - if (qualifier.matcher(candidateVersion).find()) { - rules.reject("Rejecting qualifier " + qualifier); - log.println("Rejecting " + moduleIdentifier + " version " + candidateVersion + " because of qualifier '" + qualifier + "'"); - } - }); - var rejected = rejectedVersionsPerModule.get(moduleIdentifier.toString()); - if (rejected != null) { - var exclusion = Pattern.compile(rejected); - if (exclusion.matcher(candidateVersion).find()) { - rules.reject("Rejecting version " + candidateVersion + " because of configuration. It matches regular expression: " + rejected); - log.println("Rejecting version " + candidateVersion + " because of configuration. It matches regular expression: " + rejected); - } - } - maybeRejectVersionByMinorMajor(rules, allowMajorUpdate, allowMinorUpdate, required, candidateVersion, log, candidateModule); - } - } - } - ); - }); - - Set ignoredModules = getIgnoredModules().get(); + var ignoredModules = getIgnoredModules().get(); - model.getLibrariesTable() + var allDetails = model.getLibrariesTable() .stream() .filter(library -> !ignoredModules.contains(library.getModule())) .filter(library -> library.getVersion().getReference() != null || !requiredVersionOf(library).isEmpty()) - .map(library -> requirePom(dependencies, library)) - .forEach(dependency -> detachedConfiguration.getDependencies().add(dependency)); - - ResolutionResult resolutionResult = detachedConfiguration.getIncoming() - .getResolutionResult(); - resolutionResult - .allComponents(result -> { - ModuleVersionIdentifier mid = result.getModuleVersion(); - String latest = mid.getVersion(); - Status targetStatus = Status.detectStatus(latest); - log.println("Latest release of " + mid.getModule() + " is " + latest + " (status " + targetStatus + ")"); - model.findLibrary(mid.getGroup(), mid.getName()).ifPresent(library -> { - VersionModel version = library.getVersion(); - String reference = version.getReference(); - if (reference != null) { - model.findVersion(reference).ifPresent(referencedVersion -> { - RichVersion richVersion = referencedVersion.getVersion(); - if (supportsUpdate(richVersion)) { - String require = richVersion.getRequire(); - Status sourceStatus = Status.detectStatus(require); - if (!Objects.equals(require, latest) && targetStatus.isAsStableOrMoreStableThan(sourceStatus)) { - log.println("Updating required version from " + require + " to " + latest); - String lookup = "(" + reference + "\\s*=\\s*[\"'])(.+?)([\"'])"; - int lineNb = referencedVersion.getPosition().line() - 1; - String line = lines.get(lineNb); - Matcher m = Pattern.compile(lookup).matcher(line); - if (m.find()) { - lines.set(lineNb, m.replaceAll("$1" + latest + "$3")); - } else { - log.println("Line " + lineNb + " contains unsupported notation, automatic updating failed"); - } + .parallel() + .map(library -> findBestVersion(model, log, library, rejectedQualifiers, rejectedVersionsPerModule, allowMajorUpdate, allowMinorUpdate)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + List unresolved = new ArrayList<>(); + for (var details : allDetails) { + var latest = details.acceptedVersion != null ? details.acceptedVersion : details.fallbackVersion; + if (latest!=null) { + var library = details.library; + VersionModel version = library.getVersion(); + String reference = version.getReference(); + if (reference != null) { + model.findVersion(reference).ifPresent(referencedVersion -> { + RichVersion richVersion = referencedVersion.getVersion(); + if (supportsUpdate(richVersion)) { + String require = richVersion.getRequire(); + if (!Objects.equals(require, latest.fullVersion())) { + log.println("Updating required version from " + require + " to " + latest); + String lookup = "(" + reference + "\\s*=\\s*[\"'])(.+?)([\"'])"; + int lineNb = referencedVersion.getPosition().line() - 1; + String line = lines.get(lineNb); + Matcher m = Pattern.compile(lookup).matcher(line); + if (m.find()) { + lines.set(lineNb, m.replaceAll("$1" + latest + "$3")); + } else { + log.println("Line " + lineNb + " contains unsupported notation, automatic updating failed"); } - } else { - log.println("Version '" + reference + "' uses a notation which is not supported for automatic upgrades yet."); } - }); + } else { + log.println("[" + details.module + "] version '" + reference + "' uses a notation which is not supported for automatic upgrades yet."); + } + }); + } else { + String lookup = "(version\\s*=\\s*[\"'])(.+?)([\"'])"; + int lineNb = library.getPosition().line() - 1; + String line = lines.get(lineNb); + Matcher m = Pattern.compile(lookup).matcher(line); + if (m.find()) { + lines.set(lineNb, m.replaceAll("$1" + latest + "$3")); } else { - String lookup = "(version\\s*=\\s*[\"'])(.+?)([\"'])"; - int lineNb = library.getPosition().line() - 1; - String line = lines.get(lineNb); - Matcher m = Pattern.compile(lookup).matcher(line); + lookup = "(\\s*=\\s*[\"'])(" + library.getGroup() + "):(" + library.getName() + "):(.+?)([\"'])"; + m = Pattern.compile(lookup).matcher(line); if (m.find()) { - lines.set(lineNb, m.replaceAll("$1" + latest + "$3")); + lines.set(lineNb, m.replaceAll("$1$2:$3:" + latest + "$5")); } else { - lookup = "(\\s*=\\s*[\"'])(" + library.getGroup() + "):(" + library.getName() + "):(.+?)([\"'])"; - m = Pattern.compile(lookup).matcher(line); - if (m.find()) { - lines.set(lineNb, m.replaceAll("$1$2:$3:" + latest + "$5")); - } else { - log.println("Line " + lineNb + " contains unsupported notation, automatic updating failed"); - } + log.println("Line " + lineNb + " contains unsupported notation, automatic updating failed"); } } - }); - }); + } + } else { + unresolved.add("Cannot resolve module " + details.module); + } + } getLogger().lifecycle("Writing updated catalog at " + outputCatalog); try (PrintWriter writer = newPrintWriter(outputCatalog)) { lines.forEach(writer::println); } - String errors = resolutionResult.getAllDependencies() - .stream() - .filter(UnresolvedDependencyResult.class::isInstance) - .map(UnresolvedDependencyResult.class::cast) - .map(r -> { - log.println("Unresolved dependency " + r.getAttempted().getDisplayName()); - log.println(" reason " + r.getAttemptedReason()); - log.println(" failure"); - r.getFailure().printStackTrace(log); - return "\n - " + r.getAttempted().getDisplayName() + " -> " + r.getFailure().getMessage(); - }) - .collect(Collectors.joining("")); - if (!errors.isEmpty()) { + if (!unresolved.isEmpty()) { + var errors = unresolved.stream().map(s -> " - " + s).collect(Collectors.joining("\n")); boolean fail = getFailWhenNoVersionFound().getOrElse(Boolean.TRUE); if (fail) { throw new GradleException("Some modules couldn't be updated because of the following reasons:" + errors); @@ -284,26 +242,98 @@ private void updateCatalog(File inputCatalog, File outputCatalog, File logFile) } } + private Optional findBestVersion(VersionCatalogTomlModel model, + PrintWriter log, + Library library, + List rejectedQualifiers, + Map rejectedVersionsPerModule, + boolean allowMajorUpdates, + boolean allowMinorUpdates) { + var reference = library.getVersion().getReference(); + String version; + if (reference != null) { + version = model.findVersion(reference).map(VersionModel::getVersion).map(RichVersion::getRequire).orElse(null); + } else { + version = library.getVersion().getVersion().getRequire(); + } + if (version != null) { + var group = library.getGroup(); + var name = library.getName(); + var currentVersion = VersionParser.parse(version); + var module = group + ":" + name; + var candidateDetails = new DefaultCandidateDetails(log, library, currentVersion); + var comparableVersions = fetchVersions(group, name); + for (var candidateVersion : comparableVersions) { + candidateDetails.prepare(candidateVersion); + var candidateStatus = Status.detectStatus(candidateVersion.fullVersion()); + var sourceStatus = Status.detectStatus(currentVersion.fullVersion()); + if (!candidateStatus.isAsStableOrMoreStableThan(sourceStatus)) { + candidateDetails.rejectCandidate("it's not as stable as " + sourceStatus); + } + if (candidateVersion.qualifier().isPresent()) { + var candidateQualifier = candidateVersion.qualifier().get(); + rejectedQualifiers.forEach(qualifier -> { + if (qualifier.matcher(candidateQualifier).find()) { + candidateDetails.rejectCandidate("of qualifier '" + qualifier + "'"); + } + }); + } + var rejected = rejectedVersionsPerModule.get(module); + if (rejected != null) { + var exclusion = Pattern.compile(rejected); + if (exclusion.matcher(candidateVersion.fullVersion()).find()) { + candidateDetails.rejectCandidate("of configuration. It matches regular expression: " + rejected); + } + } + maybeRejectVersionByMinorMajor(allowMajorUpdates, allowMinorUpdates, currentVersion, candidateVersion, candidateDetails); + if (!candidateDetails.isRejected()) { + processCandidate(candidateDetails); + } + if (!candidateDetails.isRejected() && !candidateDetails.hasFallback()) { + candidateDetails.acceptCandidate(); + break; + } + } + return Optional.of(candidateDetails); + } + return Optional.empty(); + } + + public List fetchVersions(String groupId, String artifactId) { + var uris = getRepositoryBaseUris().get(); + for (var baseUrl : uris) { + var metadata = URI.create(baseUrl.toString() + "/" + groupId.replace('.', '/') + "/" + artifactId + "/maven-metadata.xml"); + var data = Downloader.doDownload(metadata); + if (data != null) { + var list = MavenMetadataVersionHelper.findReleasesFrom(data) + .stream() + .sorted(reverseOrder()) + .toList(); + if (!list.isEmpty()) { + // Goto next repository + return list; + } + } + } + return List.of(); + } + // Visible for testing - static void maybeRejectVersionByMinorMajor(ComponentSelection rules, - boolean allowMajorUpdate, + static void maybeRejectVersionByMinorMajor(boolean allowMajorUpdate, boolean allowMinorUpdate, - String currentVersion, - String candidateVersion, - PrintWriter log, - ModuleComponentIdentifier candidateModule) { + ComparableVersion currentVersion, + ComparableVersion candidateVersion, + CandidateDetails details) { if (!allowMajorUpdate || !allowMinorUpdate) { - int major = majorVersionOf(currentVersion); - int candidateMajor = majorVersionOf(candidateVersion); + int major = currentVersion.major().orElse(0); + int candidateMajor = candidateVersion.major().orElse(0); if (major != candidateMajor && !allowMajorUpdate) { - rules.reject("Rejecting major version " + candidateMajor); - log.println("Rejecting " + candidateModule.getModuleIdentifier() + " version " + candidateVersion + " because it's not the same major version"); + details.rejectCandidate("it's not the same major version as current : " + currentVersion + " (current) vs " + candidateVersion + " (candidate)"); } else if (major == candidateMajor && !allowMinorUpdate) { - int minor = minorVersionOf(currentVersion); - int candidateMinor = minorVersionOf(candidateVersion); - if (minor!=candidateMinor) { - rules.reject("Rejecting minor version " + candidateMinor); - log.println("Rejecting " + candidateModule.getModuleIdentifier() + " version " + candidateVersion + " because it's not the same minor version"); + int minor = currentVersion.minor().orElse(0); + int candidateMinor = candidateVersion.minor().orElse(0); + if (minor != candidateMinor) { + details.rejectCandidate("it's not the same minor version : " + currentVersion + " (current) vs " + candidateVersion + " (candidate)"); } } } @@ -320,48 +350,131 @@ private static String requiredVersionOf(Library library) { return ""; } - private static Dependency requirePom(DependencyHandler dependencies, Library library) { - ExternalModuleDependency dependency = (ExternalModuleDependency) dependencies.create(library.getGroup() + ":" + library.getName() + ":+"); - dependency.artifact(artifact -> artifact.setType("pom")); - return dependency; - } - private static PrintWriter newPrintWriter(File file) throws FileNotFoundException { return new PrintWriter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)); } - static int majorVersionOf(String version) { - int idx = version.indexOf("."); - if (idx < 0) { - return safeParseInt(version); - } - return safeParseInt(version.substring(0, idx)); + /** + * Stateful details about a candidate. Allows subclasses to + * perform custom selection. + */ + protected interface CandidateDetails { + /** + * The candidate module, in the group:name format + * @return the module id + */ + String getModule(); + + /** + * The current version of a module, as found in the catalog + * @return the current version + */ + ComparableVersion getCurrentVersion(); + + /** + * A candidate version of the module, as found in a repository + * @return the candidate version + */ + ComparableVersion getCandidateVersion(); + + /** + * Tells that this version should be selected if no better + * match is found. The first fallback will be used, any + * subsequent call to this method once a fallback is set + * will be ignored. + */ + void acceptAsFallback(); + + /** + * Rejects a candidate with a reason. + * @param reason the reason to reject the candidate + */ + void rejectCandidate(String reason); + + /** + * Accepts a candidate. No other candidate will be tested. + */ + void acceptCandidate(); } - static int minorVersionOf(String version) { - int idx = version.indexOf("."); - if (idx < 0) { - return 0; + private static class DefaultCandidateDetails implements CandidateDetails { + private final Library library; + private final PrintWriter log; + private final String module; + private final ComparableVersion currentVersion; + private ComparableVersion candidateVersion; + private ComparableVersion acceptedVersion; + private ComparableVersion fallbackVersion; + private boolean rejected; + + private DefaultCandidateDetails(PrintWriter log, + Library library, + ComparableVersion currentVersion) { + this.log = log; + this.library = library; + this.module = library.getGroup() + ":" + library.getName(); + this.currentVersion = currentVersion; } - var bugfixIdx = version.indexOf(".", idx + 1); - if (bugfixIdx < 0) { - return safeParseInt(version.substring(idx + 1)); + + @Override + public String getModule() { + return module; + } + + @Override + public ComparableVersion getCurrentVersion() { + return currentVersion; } - return safeParseInt(version.substring(idx + 1, bugfixIdx)); - } - private static int safeParseInt(String pollutedVersion) { - int idx = 0; - while (idx < pollutedVersion.length() && Character.isDigit(pollutedVersion.charAt(idx))) { - idx++; + @Override + public ComparableVersion getCandidateVersion() { + return candidateVersion; } - if (idx == 0) { - return 0; + + @Override + public void acceptAsFallback() { + if (fallbackVersion == null) { + fallbackVersion = candidateVersion; + String message = "[" + module + "] Accepting version '" + candidateVersion + "' as fallback in case no better match is found"; + log.println(message); + } + rejected = true; } - try { - return Integer.parseInt(pollutedVersion.substring(0, idx)); - } catch (NumberFormatException ex) { - return 0; + + @Override + public void rejectCandidate(String reason) { + rejected = true; + String message = "[" + module + "] Rejecting version '" + candidateVersion + "' because " + reason; + log.println(message); + } + + @Override + public void acceptCandidate() { + acceptedVersion = candidateVersion; + String message; + if (currentVersion.equals(candidateVersion)) { + message = "[" + module + "] Current version " + currentVersion + " is suitable"; + } else { + message = "[" + module + "] Accepting candidate " + candidateVersion + " as replacement to " + currentVersion; + } + log.println(message); + } + + public boolean isRejected() { + return rejected; + } + + public boolean hasFallback() { + return fallbackVersion != null; + } + + public boolean isAccepted() { + return acceptedVersion != null; + } + + public void prepare(ComparableVersion candidateVersion) { + this.candidateVersion = candidateVersion; + this.rejected = false; } } } diff --git a/src/main/java/io/micronaut/build/compat/FindBaselineTask.java b/src/main/java/io/micronaut/build/compat/FindBaselineTask.java index 7e7be7bf..3e3a6872 100644 --- a/src/main/java/io/micronaut/build/compat/FindBaselineTask.java +++ b/src/main/java/io/micronaut/build/compat/FindBaselineTask.java @@ -16,6 +16,8 @@ package io.micronaut.build.compat; import io.micronaut.build.utils.ExternalURLService; +import io.micronaut.build.utils.ComparableVersion; +import io.micronaut.build.utils.VersionParser; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.file.RegularFileProperty; @@ -87,9 +89,9 @@ protected Provider getMavenMetadata() { @TaskAction public void execute() throws IOException { byte[] metadata = getMavenMetadata().get(); - List releases = MavenMetadataVersionHelper.findReleasesFrom(metadata); - VersionModel current = VersionModel.of(trimVersion()); - Optional previous = MavenMetadataVersionHelper.findPreviousReleaseFor(current, releases); + List releases = MavenMetadataVersionHelper.findReleasesFrom(metadata); + ComparableVersion current = VersionParser.parse(trimVersion()); + Optional previous = MavenMetadataVersionHelper.findPreviousReleaseFor(current, releases); if (!previous.isPresent()) { throw new IllegalStateException("Could not find a previous version for " + current); } diff --git a/src/main/java/io/micronaut/build/compat/MavenMetadataVersionHelper.java b/src/main/java/io/micronaut/build/compat/MavenMetadataVersionHelper.java index 1b460ab0..1ee3ef22 100644 --- a/src/main/java/io/micronaut/build/compat/MavenMetadataVersionHelper.java +++ b/src/main/java/io/micronaut/build/compat/MavenMetadataVersionHelper.java @@ -15,7 +15,8 @@ */ package io.micronaut.build.compat; -import org.gradle.api.GradleException; +import io.micronaut.build.utils.ComparableVersion; +import io.micronaut.build.utils.VersionParser; import java.io.BufferedReader; import java.io.IOException; @@ -23,9 +24,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Optional; -import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -33,12 +32,12 @@ public abstract class MavenMetadataVersionHelper { private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+\\.\\d+\\.\\d+)([.-]\\w+)?$"); private static final String VERSION_OPEN_TAG = ""; private static final String VERSION_CLOSE_TAG = ""; - + private MavenMetadataVersionHelper() { } - public static List findReleasesFrom(byte[] mavenMetadata) { + public static List findReleasesFrom(byte[] mavenMetadata) { List allVersions = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new StringReader(new String(mavenMetadata, StandardCharsets.UTF_8)))) { String line; @@ -49,30 +48,19 @@ public static List findReleasesFrom(byte[] mavenMetadata) { } } return allVersions.stream() - .map(version -> { - Matcher m = VERSION_PATTERN.matcher(version); - if (m.find()) { - if (m.group(2) != null) { - // discard non release versions like M2, RC1, etc - return null; - } - return m.group(1); - } - return null; - }) - .filter(Objects::nonNull) - .map(VersionModel::of) - .sorted() - .collect(Collectors.toList()); + .map(VersionParser::parse) + .sorted() + .collect(Collectors.toList()); } catch (IOException e) { - throw new GradleException("Error parsing maven-metadata.xml", e); + return List.of(); } } - public static Optional findPreviousReleaseFor(VersionModel version, List releases) { + public static Optional findPreviousReleaseFor(ComparableVersion version, List releases) { return releases.stream() - .filter(v -> v.compareTo(version) < 0) - .reduce((a, b) -> b); + .filter(v -> v.qualifier().isEmpty()) + .filter(v -> v.compareTo(version) < 0) + .reduce((a, b) -> b); } } diff --git a/src/main/java/io/micronaut/build/compat/VersionModel.java b/src/main/java/io/micronaut/build/compat/VersionModel.java deleted file mode 100644 index f8aeeb77..00000000 --- a/src/main/java/io/micronaut/build/compat/VersionModel.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2003-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.micronaut.build.compat; - -public class VersionModel implements Comparable { - private final String current; - private final int currentAsInt; - private final VersionModel leaf; - - public static VersionModel of(String version) { - int idx = version.indexOf("."); - if (idx < 0) { - return new VersionModel(version, null); - } - return new VersionModel(version.substring(0, idx), of(version.substring(idx + 1))); - } - - private VersionModel(String current, VersionModel leaf) { - this.current = current; - this.currentAsInt = parseInt(current); - this.leaf = leaf; - } - - private static int parseInt(String current) { - try { - return Integer.parseInt(current); - } catch (NumberFormatException e) { - return 0; - } - } - - @Override - public int compareTo(VersionModel o) { - int result = Integer.compare(currentAsInt, o.currentAsInt); - if (result != 0) { - return result; - } - if (leaf == null && o.leaf == null) { - return 0; - } - if (leaf == null) { - return -1; - } - if (o.leaf == null) { - return 1; - } - return leaf.compareTo(o.leaf); - } - - public String getCurrent() { - return current; - } - - public int getCurrentAsInt() { - return currentAsInt; - } - - public String getVersion() { - if (leaf == null) { - return current; - } - return current + "." + leaf; - } - - @Override - public String toString() { - return getVersion(); - } -} diff --git a/src/main/java/io/micronaut/build/utils/ComparableVersion.java b/src/main/java/io/micronaut/build/utils/ComparableVersion.java new file mode 100644 index 00000000..d4be16bc --- /dev/null +++ b/src/main/java/io/micronaut/build/utils/ComparableVersion.java @@ -0,0 +1,153 @@ +package io.micronaut.build.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** + * A version model, used for comparing versions when performing automatic updates. + * The version model is primarily aimed at supporting semantic versioning, but in + * order to take into account more real world use cases, it also has support for + * qualifiers and "extra" version components. + */ +public final class ComparableVersion implements Comparable { + private static final List PARTIAL_QUALIFIER_ORDER = List.of("snapshot", "alpha", "beta", "rc", "final"); + + private final String fullVersion; + private final Integer major; + private final Integer minor; + private final Integer patch; + private final String qualifier; + private final Integer qualifierVersion; + private final List extraVersions; + + ComparableVersion( + String fullVersion, + Integer major, + Integer minor, + Integer patch, + String qualifier, + Integer qualifierVersion, List extraVersions + ) { + this.fullVersion = fullVersion; + this.major = major; + this.minor = minor; + this.patch = patch; + this.qualifier = qualifier; + this.qualifierVersion = qualifierVersion; + this.extraVersions = extraVersions; + } + + public String fullVersion() { + return fullVersion; + } + + public Optional major() { + return Optional.ofNullable(major); + } + + public Optional minor() { + return Optional.ofNullable(minor); + } + + public Optional patch() { + return Optional.ofNullable(patch); + } + + public Optional qualifier() { + return Optional.ofNullable(qualifier); + } + + public Optional qualifierVersion() { + return Optional.ofNullable(qualifierVersion); + } + + public List getExtraVersions() { + return extraVersions; + } + + public List getVersionComponents() { + var all = new ArrayList(3 + extraVersions.size()); + all.add(Objects.requireNonNullElse(major, 0)); + all.add(Objects.requireNonNullElse(minor, 0)); + all.add(Objects.requireNonNullElse(patch, 0)); + all.addAll(extraVersions); + return all; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + ComparableVersion that = (ComparableVersion) o; + return Objects.equals(fullVersion, that.fullVersion); + } + + @Override + public int hashCode() { + return Objects.hashCode(fullVersion); + } + + @Override + public String toString() { + return fullVersion; + } + + @Override + public int compareTo(ComparableVersion o) { + var components = getVersionComponents(); + var otherComponents = o.getVersionComponents(); + + if (components.size() != otherComponents.size()) { + if (components.size() < otherComponents.size()) { + components = new ArrayList<>(components); + while (components.size() < otherComponents.size()) { + components.add(0); + } + } else { + otherComponents = new ArrayList<>(otherComponents); + while (otherComponents.size() < components.size()) { + otherComponents.add(0); + } + } + } + + for (int i = 0; i < components.size(); i++) { + int result = components.get(i).compareTo(otherComponents.get(i)); + if (result != 0) { + return result; + } + } + + if (qualifier == null && o.qualifier != null) { + return 1; + } else if (qualifier != null && o.qualifier == null) { + return -1; + } else if (qualifier != null) { + int thisQualifierIndex = PARTIAL_QUALIFIER_ORDER.indexOf(qualifier.toLowerCase(Locale.US)); + int otherQualifierIndex = PARTIAL_QUALIFIER_ORDER.indexOf(o.qualifier.toLowerCase(Locale.US)); + + if (thisQualifierIndex != otherQualifierIndex) { + return Integer.compare(thisQualifierIndex, otherQualifierIndex); + } + } + + if (qualifierVersion != null && o.qualifierVersion != null) { + return qualifierVersion.compareTo(o.qualifierVersion); + } else if (qualifierVersion != null) { + return 1; + } else if (o.qualifierVersion != null) { + return -1; + } + + return 0; + } + +} diff --git a/src/main/java/io/micronaut/build/utils/Downloader.java b/src/main/java/io/micronaut/build/utils/Downloader.java new file mode 100644 index 00000000..140ef5be --- /dev/null +++ b/src/main/java/io/micronaut/build/utils/Downloader.java @@ -0,0 +1,38 @@ +/* + * Copyright 2003-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.build.utils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; + +public class Downloader { + public static byte[] doDownload(URI uri) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + try (InputStream stream = uri.toURL().openStream()) { + byte[] buffer = new byte[4096]; + int read; + while ((read = stream.read(buffer)) > 0) { + outputStream.write(buffer, 0, read); + } + } catch (IOException e) { + return null; + } + return outputStream.toByteArray(); + } +} diff --git a/src/main/java/io/micronaut/build/utils/ExternalURLService.java b/src/main/java/io/micronaut/build/utils/ExternalURLService.java index b810c3e1..6176f2cd 100644 --- a/src/main/java/io/micronaut/build/utils/ExternalURLService.java +++ b/src/main/java/io/micronaut/build/utils/ExternalURLService.java @@ -21,9 +21,6 @@ import org.gradle.api.services.BuildService; import org.gradle.api.services.BuildServiceParameters; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.util.HashSet; import java.util.Map; @@ -59,7 +56,7 @@ public Optional fetchFromURL(URI uri) { lock.unlock(); } try { - return Optional.ofNullable(responses.computeIfAbsent(uri, ExternalURLService::doDownload)); + return Optional.ofNullable(responses.computeIfAbsent(uri, Downloader::doDownload)); } finally { lock.lock(); try { @@ -71,21 +68,6 @@ public Optional fetchFromURL(URI uri) { } } - private static byte[] doDownload(URI uri) { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - try (InputStream stream = uri.toURL().openStream()) { - byte[] buffer = new byte[4096]; - int read; - while ((read = stream.read(buffer)) > 0) { - outputStream.write(buffer, 0, read); - } - } catch (IOException e) { - return null; - } - return outputStream.toByteArray(); - } - public static Provider registerOn(Project project) { return project.getGradle().getSharedServices().registerIfAbsent("ExternalURLService", ExternalURLService.class, spec -> { }); diff --git a/src/main/java/io/micronaut/build/utils/VersionParser.java b/src/main/java/io/micronaut/build/utils/VersionParser.java new file mode 100644 index 00000000..0c065693 --- /dev/null +++ b/src/main/java/io/micronaut/build/utils/VersionParser.java @@ -0,0 +1,69 @@ +/* + * Copyright 2003-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.build.utils; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +public class VersionParser { + public static Pattern SEMANTIC_VERSION = Pattern.compile("(?\\d+)[.]?((?\\d+)([.]?(?\\d+)(?([.]\\d+)+)?)?)?[-.]?((?\\p{Alpha}+)(-?(?\\d+))?)?"); + + public static ComparableVersion parse(String version) { + try { + var matcher = SEMANTIC_VERSION.matcher(version); + if (matcher.find()) { + var major = matcher.group("major"); + var minor = matcher.group("minor"); + var patch = matcher.group("patch"); + var qualifier = matcher.group("qualifier"); + var qualifierVersion = matcher.group("qualifierVersion"); + var extraVersion = matcher.group("extra"); + return new ComparableVersion( + version, + asInt(major), + asInt(minor), + asInt(patch), + qualifier, + asInt(qualifierVersion), + asList(extraVersion) + ); + } else { + return new ComparableVersion(version, null, null, null, null, null, List.of()); + } + } catch (Exception ex) { + // safety net + return new ComparableVersion(version, null, null, null, null, null, List.of()); + } + } + + private static Integer asInt(String value) { + if (value == null) { + return null; + } + return Integer.parseInt(value); + } + + private static List asList(String extraVersions) { + if (extraVersions == null) { + return List.of(); + } + return Arrays.stream(extraVersions.split("[.]")) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .toList(); + } +} diff --git a/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionCatalogUpdateTest.groovy b/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionCatalogUpdateTest.groovy index 619f5e9f..3334f27d 100644 --- a/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionCatalogUpdateTest.groovy +++ b/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionCatalogUpdateTest.groovy @@ -1,57 +1,29 @@ package io.micronaut.build.catalogs.tasks -import org.gradle.api.artifacts.ComponentSelection -import org.gradle.api.artifacts.component.ModuleComponentIdentifier + import spock.lang.Specification import static io.micronaut.build.catalogs.tasks.VersionCatalogUpdate.maybeRejectVersionByMinorMajor +import static io.micronaut.build.utils.VersionParser.parse class VersionCatalogUpdateTest extends Specification { - def "test major and minor version extraction"() { - expect: - VersionCatalogUpdate.majorVersionOf(version) == expectedMajor - VersionCatalogUpdate.minorVersionOf(version) == expectedMinor - - where: - version | expectedMajor | expectedMinor - "1.2.3" | 1 | 2 - "1.2" | 1 | 2 - "1" | 1 | 0 - "1.2.3.4" | 1 | 2 - "3.5-beta" | 3 | 5 - "3.5-beta1" | 3 | 5 - "2.1.0-rc1" | 2 | 1 - "128.256.12" | 128 | 256 - // not semantic versioning, edge cases to make sure - // the implementation is robust enough - "abc.def" | 0 | 0 - "oh.123noes" | 0 | 123 - "" | 0 | 0 - "wut" | 0 | 0 - } def "tests rejection rules"() { - def componentSelection = Mock(ComponentSelection) - def log = Stub(PrintWriter) - def id = Stub(ModuleComponentIdentifier) { - getGroup() >> "io.micronaut" - getModule() >> "micronaut-core" - getVersion() >> candidateVersion - } + def rules = Mock(VersionCatalogUpdate.CandidateDetails) + def id = "io.micronaut:micronaut-core" when: maybeRejectVersionByMinorMajor( - componentSelection, allowMajorUpdate, allowMinorUpdate, - currentVersion, - candidateVersion, - log, - id + parse(currentVersion), + parse(candidateVersion) + , + rules ) then: - reject * componentSelection.reject(_) + reject * rules.rejectCandidate(_) where: allowMajorUpdate | allowMinorUpdate | currentVersion | candidateVersion | reject diff --git a/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionParserTest.groovy b/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionParserTest.groovy new file mode 100644 index 00000000..caf59dc8 --- /dev/null +++ b/src/test/groovy/io/micronaut/build/catalogs/tasks/VersionParserTest.groovy @@ -0,0 +1,72 @@ +package io.micronaut.build.catalogs.tasks + +import io.micronaut.build.utils.ComparableVersion +import io.micronaut.build.utils.VersionParser +import spock.lang.Specification + +class VersionParserTest extends Specification { + void "parses semantic versions"() { + given: + def version = v(input) + + expect: + version.fullVersion() == input + version.major().orElse(null) == major + version.minor().orElse(null) == minor + version.patch().orElse(null) == patch + version.qualifier().orElse(null) == qualifier + version.qualifierVersion().orElse(null) == qualifierVersion + version.getExtraVersions() == extraVersions + version.getVersionComponents() == allVersions + + where: + input | major | minor | patch | qualifier | qualifierVersion | extraVersions | allVersions + "1" | 1 | null | null | null | null | [] | [1, 0, 0] + "1.0" | 1 | 0 | null | null | null | [] | [1, 0, 0] + "11.0" | 11 | 0 | null | null | null | [] | [11, 0, 0] + "11.3.0" | 11 | 3 | 0 | null | null | [] | [11, 3, 0] + "11.3.1" | 11 | 3 | 1 | null | null | [] | [11, 3, 1] + "1-SNAPSHOT" | 1 | null | null | "SNAPSHOT" | null | [] | [1, 0, 0] + "1.0-beta" | 1 | 0 | null | "beta" | null | [] | [1, 0, 0] + "1.0-beta2" | 1 | 0 | null | "beta" | 2 | [] | [1, 0, 0] + "1.5.7-beta-33" | 1 | 5 | 7 | "beta" | 33 | [] | [1, 5, 7] + "1.5.7.foo" | 1 | 5 | 7 | "foo" | null | [] | [1, 5, 7] + "1.5.7.4" | 1 | 5 | 7 | null | null | [4] | [1, 5, 7, 4] + "1.5.7.45.0.1" | 1 | 5 | 7 | null | null | [45, 0, 1] | [1, 5, 7, 45, 0, 1] + "1.5.7.45.0.1-SNAPSHOT" | 1 | 5 | 7 | "SNAPSHOT" | null | [45, 0, 1] | [1, 5, 7, 45, 0, 1] + "1.1.0-9f31d6308e7ebbc3d7904b64ebb9f61f7e22a968" | 1 | 1 | 0 | null | null | [] | [1, 1, 0] + } + + def "compares versions"() { + expect: + verifyAll { + v("1.0") == v("1") + v("1.0") > v("0.9") + v("1.0.1") > v("1.0") + v("1.1.0") > v("1.0.0") + v("1.0") < v("2.0") + v("1.0.0.1") > v("1.0.0") + v("1.0.0") > v("1.0.0-beta-1") + v("0.5") > v("0.0.5") + v("1.0.0-alpha") < v("1.0.0-beta") + v("1.0.0-beta") < v("1.0.0-rc") + v("1.0.0-rc") < v("1.0.0-final") + v("1.0.0-final") < v("1.0.0") + v("1.0.0-beta-1") < v("1.0.0-beta-2") + v("unknown") < v("1.0") + v("1.22") > v("1.21") + v("1.1.1") > v("1.1") + v("1") < v("1.1") + v("1.0") < v("1.0.1") + v("0.9") < v("0.10") + v("0.1") < v("1.3.2") + v("3.4.5") < v("3.5.0") + v("3.5.0") > v("3.4.5") + v("4.0.0-M1") < v("4.0.0-M2") + } + } + + private static ComparableVersion v(String version) { + return VersionParser.parse(version) + } +} diff --git a/src/test/groovy/io/micronaut/build/compat/MavenMetadataVersionParserTest.groovy b/src/test/groovy/io/micronaut/build/compat/MavenMetadataVersionParserTest.groovy index 42655261..bcd7d9ac 100644 --- a/src/test/groovy/io/micronaut/build/compat/MavenMetadataVersionParserTest.groovy +++ b/src/test/groovy/io/micronaut/build/compat/MavenMetadataVersionParserTest.groovy @@ -1,5 +1,7 @@ package io.micronaut.build.compat +import io.micronaut.build.utils.ComparableVersion +import io.micronaut.build.utils.VersionParser import spock.lang.Specification class MavenMetadataVersionParserTest extends Specification { @@ -10,9 +12,7 @@ class MavenMetadataVersionParserTest extends Specification { def versions = MavenMetadataVersionHelper.findReleasesFrom(metadata) then: - versions.size() == 125 - versions[0].version == "1.0.0" - versions[124].version == "3.8.6" + versions.size() == 147 previousReleaseOf("3.8.7", versions) == "3.8.6" previousReleaseOf("1.1.0", versions) == "1.0.5" @@ -21,7 +21,7 @@ class MavenMetadataVersionParserTest extends Specification { previousReleaseOf("1.0.5", versions) == "1.0.4" } - static String previousReleaseOf(String version, List versions) { - MavenMetadataVersionHelper.findPreviousReleaseFor(VersionModel.of(version), versions).orElse(null) + static String previousReleaseOf(String version, List versions) { + MavenMetadataVersionHelper.findPreviousReleaseFor(VersionParser.parse(version), versions).orElse(null) } } diff --git a/src/test/groovy/io/micronaut/build/compat/VersionModelTest.groovy b/src/test/groovy/io/micronaut/build/compat/VersionModelTest.groovy deleted file mode 100644 index 8817f85d..00000000 --- a/src/test/groovy/io/micronaut/build/compat/VersionModelTest.groovy +++ /dev/null @@ -1,23 +0,0 @@ -package io.micronaut.build.compat - -import spock.lang.Specification - -class VersionModelTest extends Specification { - def "compares versions"() { - expect: - v("1.0") == v("1.0") - v("1.0") < v("1.1") - v("1.2") > v("1.1") - v("1.1.1") > v("1.1") - v("1") < v("1.1") - v("1.0") < v("1.0.1") - v("0.9") < v("0.10") - v("0.1") < v("1.3.2") - v("3.4.5") < v("3.5.0") - v("3.5.0") > v("3.4.5") - } - - static VersionModel v(String version) { - VersionModel.of(version) - } -}