From f2cbd916430e522fdb344c028bf3517f508b00ab Mon Sep 17 00:00:00 2001 From: BoykoAlex Date: Thu, 3 Sep 2026 11:22:56 -0700 Subject: [PATCH 1/2] WIP Signed-off-by: BoykoAlex --- .../boot/maven/PomInlayHintHandler.java | 41 ++++- .../generations/UpdateBootVersion.java | 101 ++++++++---- .../generations/VersionValidationUtils.java | 14 +- .../generations/json/Generation.java | 47 ++++++ .../json/ResolvedSpringProject.java | 28 +++- .../boot/maven/PomInlayHintHandlerTest.java | 56 ++++++- .../generations/UpdateBootVersionTest.java | 145 ++++++++++++++++++ .../ProjectGenerationsValidationTest.java | 8 + .../test/SampleProjectsProvider.java | 8 +- 9 files changed, 405 insertions(+), 43 deletions(-) create mode 100644 headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersionTest.java diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java index f271748206..767bca8ac1 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Supplier; @@ -135,11 +136,13 @@ public List handle(TextDocument doc, Range range, CancelChecker cance try { SortedVersions versions = null; + boolean usedMavenMetadata = false; if (bootJavaConfig.isUseProjectBuildFileForVersionValidation() && ProjectBuild.MAVEN_PROJECT_TYPE.equals(jp.getProjectBuild().getType())) { try { MavenMetadata metadata = mavenMetadataProvider.getMetadata(jp, "org.springframework.boot", "spring-boot"); if (metadata != null) { versions = metadata.getReleaseVersions(); + usedMavenMetadata = true; } } catch (Exception e) { // Logged in provider, fallback will happen below @@ -148,19 +151,45 @@ public List handle(TextDocument doc, Range range, CancelChecker cance ResolvedSpringProject genProject = generationsProvider.getProject(SpringProjectUtil.SPRING_BOOT); if (versions == null && genProject != null) { - versions = new SortedVersions(genProject.getReleases()); + versions = new SortedVersions(genProject.getLatestPatchVersions()); } if (versions != null) { - Version latestPatch = versions.getNewerLatestPatchRelease(currentVersion).orElse(null); - if (latestPatch != null) { + // type is "oss"/"enterprise" when sourced from a generation's latestPatch map, + // or "" for the generic Maven-metadata/single-candidate case. + Optional currentGeneration = usedMavenMetadata || genProject == null + ? Optional.empty() + : genProject.findGeneration(currentVersion); + + Map patchCandidatesByType; + if (currentGeneration.isPresent()) { + patchCandidatesByType = currentGeneration.get().getLatestPatchByType(); + } else { + // Maven metadata and "generation not found" both have no oss/enterprise + // concept - just the single, generic candidate from the sorted version list. + patchCandidatesByType = versions.getNewerLatestPatchRelease(currentVersion) + .map(latest -> Map.of("", latest)) + .orElse(Map.of()); + } + + List> patchTargets = patchCandidatesByType.entrySet().stream() + .filter(e -> e.getValue().compareTo(currentVersion) > 0) + .sorted(Map.Entry.comparingByValue().reversed()) + .collect(Collectors.toList()); + + for (Map.Entry patchTarget : patchTargets) { + String type = patchTarget.getKey(); + Version latestPatch = patchTarget.getValue(); + String title = type.isEmpty() ? "Upgrade to the Latest Patch" + : "Upgrade to the Latest Patch (" + VersionValidationUtils.patchTypeLabel(type) + ")"; + inlayHintProviders.add(new InlayHintWithLazyPosition(() -> { Command command = new Command(); - command.setTitle("Upgrade to the Latest Patch"); + command.setTitle(title); command.setCommand(SpringBootPatchUpgrade.CMD_UPGRADE_SPRING_BOOT_PATCH); command.setArguments(List.of(jp.getLocationUri().toASCIIString(), latestPatch.toString(), false)); - InlayHintLabelPart label = new InlayHintLabelPart("Upgrade to the Latest Patch"); + InlayHintLabelPart label = new InlayHintLabelPart(title); label.setCommand(command); InlayHint hint = new InlayHint(); @@ -195,7 +224,7 @@ public List handle(TextDocument doc, Range range, CancelChecker cance return Collections.emptyList(); })); } - + if (genProject != null) { Generation generation = GenerationsValidator.getGenerationForJavaProject(jp, genProject, genProject.getSlug()); if (generation != null && VersionValidationUtils.isOssValid(generation)) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java index e306e1a4e6..65cb0fbe2d 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java @@ -13,7 +13,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionKind; @@ -26,6 +28,8 @@ import org.springframework.ide.vscode.boot.app.BootJavaConfig; import org.springframework.ide.vscode.boot.app.BootLanguageServerInitializer; import org.springframework.ide.vscode.boot.java.rewrite.SpringBootPatchUpgrade; +import org.springframework.ide.vscode.boot.validation.generations.json.Generation; +import org.springframework.ide.vscode.boot.validation.generations.json.ResolvedSpringProject; import org.springframework.ide.vscode.boot.validation.generations.preferences.VersionValidationProblemType; import org.springframework.ide.vscode.commons.Version; import org.springframework.ide.vscode.commons.java.IJavaProject; @@ -60,7 +64,8 @@ public Collection validate(IJavaProject javaProject, Version javaPro long start = System.currentTimeMillis(); try { SortedVersions versions = null; - + Map patchCandidatesByType; + if (bootJavaConfig.isUseProjectBuildFileForVersionValidation() && ProjectBuild.MAVEN_PROJECT_TYPE.equals(javaProject.getProjectBuild().getType())) { try { MavenMetadata metadata = mavenMetadataProvider.getMetadata(javaProject, "org.springframework.boot", "spring-boot"); @@ -71,26 +76,37 @@ public Collection validate(IJavaProject javaProject, Version javaPro // Logged in provider, fallback will happen below } } - + + Optional currentGeneration = Optional.empty(); if (versions == null) { - List rawReleases = springProjectsProvider.getProject(SpringProjectUtil.SPRING_BOOT).getReleases(); - versions = new SortedVersions(rawReleases); + ResolvedSpringProject bootProject = springProjectsProvider.getProject(SpringProjectUtil.SPRING_BOOT); + versions = new SortedVersions(bootProject.getLatestPatchVersions()); + currentGeneration = bootProject.findGeneration(javaProjectVersion); } - + + if (currentGeneration.isPresent()) { + patchCandidatesByType = currentGeneration.get().getLatestPatchByType(); + } else { + // Maven metadata and "generation not found" both have no oss/enterprise concept - + // just the single, generic candidate from the sorted version list. + patchCandidatesByType = versions.getNewerLatestPatchRelease(javaProjectVersion) + .map(latest -> Map.of("", latest)) + .orElse(Map.of()); + } + ImmutableList.Builder builder = ImmutableList.builder(); - + versions.getNewerLatestMajorRelease(javaProjectVersion) .flatMap(latest -> validateMajorVersion(javaProject, javaProjectVersion, latest)) .ifPresent(builder::add); - + versions.getNewerLatestMinorRelease(javaProjectVersion) .flatMap(latest -> validateMinorVersion(javaProject, javaProjectVersion, latest)) .ifPresent(builder::add); - - versions.getNewerLatestPatchRelease(javaProjectVersion) - .flatMap(latest -> validatePatchVersion(javaProject, javaProjectVersion, latest)) + + validatePatchVersions(javaProject, javaProjectVersion, patchCandidatesByType) .ifPresent(builder::add); - + return builder.build(); } finally { log.info("boot major/minor/patch version validation for `%s` took: %d".formatted(javaProject.getElementName(), System.currentTimeMillis() - start)); @@ -117,25 +133,58 @@ private Optional validateMinorVersion(IJavaProject javaProject, Vers return Optional.ofNullable(createDiagnostic(actions, VersionValidationProblemType.UPDATE_LATEST_MINOR_VERSION, "Newer minor version of Spring Boot available: %s".formatted(latest.toString()))); } - private Optional validatePatchVersion(IJavaProject javaProject, Version javaProjectVersion, Version latest) { - List actions = new ArrayList<>(2); - if (canProvideQuickfix(javaProject)) { - bootUpgradeOpt.map(bu -> { - CodeAction c = new CodeAction(); - c.setKind(CodeActionKind.QuickFix); - c.setTitle("Upgrade to Spring Boot " + latest.toString() + " (Maven dependency version changes only)"); - String commandId = SpringBootPatchUpgrade.CMD_UPGRADE_SPRING_BOOT_PATCH; - c.setCommand(new Command("Upgrade to Version " + latest.toString(), commandId, - ImmutableList.of(javaProject.getLocationUri().toASCIIString(), latest.toString(), false))); - return c; - }).ifPresent(actions::add); + /** + * A patch upgrade can have more than one candidate at once - e.g. a publicly available + * {@code oss} one and a commercial-only {@code enterprise} one when sourced from a + * generation's {@code latestPatch}, or a single untyped ({@code ""}) one when sourced + * from the project's resolvable Maven repository. Offers a separate quickfix per + * candidate that is newer than the project's current version, so the user can pick + * whichever they actually have access to. + */ + private Optional validatePatchVersions(IJavaProject javaProject, Version javaProjectVersion, Map candidatesByType) { + List> newerCandidates = candidatesByType.entrySet().stream() + .filter(e -> e.getValue().compareTo(javaProjectVersion) > 0) + .sorted(Map.Entry.comparingByValue().reversed()) + .collect(Collectors.toList()); + + if (newerCandidates.isEmpty()) { + return Optional.empty(); } - actions.add(openReleaseNotesCodeAction(latest)); + List actions = new ArrayList<>(); + for (Map.Entry candidate : newerCandidates) { + String type = candidate.getKey(); + Version latest = candidate.getValue(); + String qualifier = type.isEmpty() ? "Maven dependency version changes only" + : VersionValidationUtils.patchTypeLabel(type) + ", Maven dependency version changes only"; + + if (canProvideQuickfix(javaProject)) { + bootUpgradeOpt.map(bu -> { + CodeAction c = new CodeAction(); + c.setKind(CodeActionKind.QuickFix); + c.setTitle("Upgrade to Spring Boot " + latest.toString() + " (" + qualifier + ")"); + String commandId = SpringBootPatchUpgrade.CMD_UPGRADE_SPRING_BOOT_PATCH; + c.setCommand(new Command("Upgrade to Version " + latest.toString(), commandId, + ImmutableList.of(javaProject.getLocationUri().toASCIIString(), latest.toString(), false))); + return c; + }).ifPresent(actions::add); + } + + // Release notes for commercial-only patches aren't publicly published, so skip those. + if (!"enterprise".equals(type)) { + actions.add(openReleaseNotesCodeAction(latest)); + } + } + + String message = newerCandidates.size() == 1 && newerCandidates.get(0).getKey().isEmpty() + ? "Newer patch version of Spring Boot available: %s".formatted(newerCandidates.get(0).getValue()) + : newerCandidates.stream() + .map(e -> "%s (%s)".formatted(e.getValue(), VersionValidationUtils.patchTypeLabel(e.getKey()))) + .collect(Collectors.joining(", ", "Newer patch version of Spring Boot available: ", "")); - return Optional.ofNullable(createDiagnostic(actions, VersionValidationProblemType.UPDATE_LATEST_PATCH_VERSION, "Newer patch version of Spring Boot available: %s".formatted(latest.toString()))); + return Optional.ofNullable(createDiagnostic(actions, VersionValidationProblemType.UPDATE_LATEST_PATCH_VERSION, message)); } - + private static CodeAction openReleaseNotesCodeAction(Version version) { CodeAction releaseNoteLink = new CodeAction(); releaseNoteLink.setKind(CodeActionKind.QuickFix); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java index 213c0b3272..0555989301 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java @@ -42,5 +42,17 @@ public static Version getLatestSupportedRelease(ResolvedSpringProject springProj List rls = springProject.getReleases(); return rls.isEmpty() ? null : rls.get(rls.size() - 1); } - + + /** + * Human-readable label for a {@code Generation.latestPatch} entry key + * ({@code oss} or {@code enterprise}). + */ + public static String patchTypeLabel(String type) { + return switch (type) { + case "oss" -> "OSS"; + case "enterprise" -> "Enterprise"; + default -> type; + }; + } + } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java index 8963c87d6b..2fd2998d60 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java @@ -10,8 +10,11 @@ *******************************************************************************/ package org.springframework.ide.vscode.boot.validation.generations.json; +import java.util.LinkedHashMap; import java.util.Map; +import org.springframework.ide.vscode.commons.Version; + public class Generation extends JsonHalLinks { private String name; @@ -19,6 +22,7 @@ public class Generation extends JsonHalLinks { private String commercialSupportEndDate; private String initialReleaseDate; private Map linkedGenerations; + private Map latestPatch; public String getName() { return name; @@ -60,4 +64,47 @@ public void setLinkedGenerations(Map linkedGenerations) { this.linkedGenerations = linkedGenerations; } + public Map getLatestPatch() { + return latestPatch; + } + + public void setLatestPatch(Map latestPatch) { + this.latestPatch = latestPatch; + } + + /** + * The latest publicly-available (OSS) patch release for this generation, i.e. + * the {@code oss} value of the {@code latestPatch} object. Returns {@code null} + * if the generation's OSS support window has ended - at that point {@code latestPatch} + * only carries an {@code enterprise} version, which is published to Broadcom's + * commercial repository rather than public Maven Central, so it isn't a valid + * upgrade suggestion for the general public. + */ + public Version getLatestPatchVersion() { + if (latestPatch == null) { + return null; + } + return Version.parse(latestPatch.get("oss")); + } + + /** + * The {@code latestPatch} entries parsed into {@link Version}s, keyed by + * support type ({@code oss} or {@code enterprise}). Unlike + * {@link #getLatestPatchVersion()}, this exposes every known patch (including + * commercial-only ones) so callers can decide whether/how to offer each as an + * upgrade option. + */ + public Map getLatestPatchByType() { + Map result = new LinkedHashMap<>(); + if (latestPatch != null) { + for (Map.Entry e : latestPatch.entrySet()) { + Version v = Version.parse(e.getValue()); + if (v != null) { + result.put(e.getKey(), v); + } + } + } + return result; + } + } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java index e2e2031d1d..52bd88f43f 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2022, 2025 VMware, Inc. + * Copyright (c) 2022, 2026 VMware, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -11,6 +11,8 @@ package org.springframework.ide.vscode.boot.validation.generations.json; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; import org.springframework.ide.vscode.boot.validation.generations.SpringProjectsClient; @@ -49,6 +51,30 @@ public List getGenerations() throws Exception { return this.generations != null ? this.generations.getGenerations() : ImmutableList.of(); } + /** + * Latest patch version of each generation, e.g. one entry per major.minor line + * (including generations that are no longer within their support window). + * Unlike {@link #getReleases()}, this covers every known generation, not just + * the currently supported ones. + */ + public List getLatestPatchVersions() throws Exception { + return getGenerations().stream() + .map(Generation::getLatestPatchVersion) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * The generation whose name matches {@code ..x} for the given version, + * e.g. {@code 3.4.7} -> generation {@code 3.4.x}. + */ + public Optional findGeneration(Version version) throws Exception { + String generationName = version.getMajor() + "." + version.getMinor() + ".x"; + return getGenerations().stream() + .filter(g -> generationName.equals(g.getName())) + .findFirst(); + } + /** * Sorted list of released versions */ diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandlerTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandlerTest.java index a3f4a1d56c..10a5e4a98c 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandlerTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandlerTest.java @@ -153,7 +153,7 @@ void upgradePatchVersionInlay() throws Exception { ResolvedSpringProject resolvedProject = mock(ResolvedSpringProject.class); when(resolvedProject.getGenerations()).thenReturn(null); when(resolvedProject.getSlug()).thenReturn(SpringProjectUtil.SPRING_BOOT); - when(resolvedProject.getReleases()).thenReturn(List.of( + when(resolvedProject.getLatestPatchVersions()).thenReturn(List.of( Version.parse("1.5.6"), Version.parse("1.5.7"), Version.parse("1.5.8"), @@ -186,7 +186,51 @@ void upgradePatchVersionInlay() throws Exception { assertEquals(jp.getLocationUri().toASCIIString(), cmd.getArguments().get(0)); assertEquals("1.5.10", cmd.getArguments().get(1)); } - + + @Test + void upgradePatchVersionInlayOssAndEnterprise() throws Exception { + MavenJavaProject jp = projects.mavenProject("empty-boot-15-web-app"); + + TextDocument doc = new TextDocument(jp.getProjectBuild().getBuildFile().toASCIIString(), LanguageId.XML, 0, Files.readString(Paths.get(jp.getProjectBuild().getBuildFile()))); + + JavaProjectFinder projectFinder = mock(JavaProjectFinder.class); + when(projectFinder.find(any())).thenReturn(Optional.of(jp)); + + SimpleTextDocumentService documents = mock(SimpleTextDocumentService.class); + when(documents.getLatestSnapshot(anyString())).thenReturn(doc); + + SimpleLanguageServer server = mock(SimpleLanguageServer.class); + when(server.getTextDocumentService()).thenReturn(documents); + + Generation generation = mock(Generation.class); + when(generation.getLatestPatchByType()).thenReturn(java.util.Map.of( + "oss", Version.parse("1.5.10"), + "enterprise", Version.parse("1.5.12") + )); + + ResolvedSpringProject resolvedProject = mock(ResolvedSpringProject.class); + when(resolvedProject.getSlug()).thenReturn(SpringProjectUtil.SPRING_BOOT); + when(resolvedProject.getLatestPatchVersions()).thenReturn(List.of(Version.parse("1.5.8"))); + when(resolvedProject.findGeneration(any())).thenReturn(Optional.of(generation)); + + SpringProjectsProvider projectProvider = mock(SpringProjectsProvider.class); + when(projectProvider.getProject(SpringProjectUtil.SPRING_BOOT)).thenReturn(resolvedProject); + + MavenMetadataProvider mavenMetadataProvider = mock(MavenMetadataProvider.class); + PomInlayHintHandler inlayHandler = new PomInlayHintHandler(server, projectFinder, ProjectObserver.NULL, projectProvider, mavenMetadataProvider, new BootJavaConfig(new SettingsStore())); + + List hints = inlayHandler.handle(doc, doc.toRange(0, doc.getLength()), mock(CancelChecker.class)); + assertEquals(2, hints.size()); + + InlayHintLabelPart enterpriseLabel = hints.get(0).getLabel().getRight().get(0); + assertEquals("Upgrade to the Latest Patch (Enterprise)", enterpriseLabel.getValue()); + assertEquals("1.5.12", enterpriseLabel.getCommand().getArguments().get(1)); + + InlayHintLabelPart ossLabel = hints.get(1).getLabel().getRight().get(0); + assertEquals("Upgrade to the Latest Patch (OSS)", ossLabel.getValue()); + assertEquals("1.5.10", ossLabel.getCommand().getArguments().get(1)); + } + @Test void noInlayHintOnEmptyVersionTag() throws Exception { MavenJavaProject jp = projects.mavenProject("empty-boot-15-web-app"); @@ -210,7 +254,7 @@ void noInlayHintOnEmptyVersionTag() throws Exception { ResolvedSpringProject resolvedProject = mock(ResolvedSpringProject.class); when(resolvedProject.getGenerations()).thenReturn(null); when(resolvedProject.getSlug()).thenReturn(SpringProjectUtil.SPRING_BOOT); - when(resolvedProject.getReleases()).thenReturn(List.of( + when(resolvedProject.getLatestPatchVersions()).thenReturn(List.of( Version.parse("1.5.6"), Version.parse("1.5.7"), Version.parse("1.5.8"), @@ -252,7 +296,7 @@ void noInlayHintOnEmptyVersionWithSpacesTag() throws Exception { ResolvedSpringProject resolvedProject = mock(ResolvedSpringProject.class); when(resolvedProject.getGenerations()).thenReturn(null); when(resolvedProject.getSlug()).thenReturn(SpringProjectUtil.SPRING_BOOT); - when(resolvedProject.getReleases()).thenReturn(List.of( + when(resolvedProject.getLatestPatchVersions()).thenReturn(List.of( Version.parse("1.5.6"), Version.parse("1.5.7"), Version.parse("1.5.8"), @@ -294,7 +338,7 @@ void noInlayHintOnVersionTagWithNonParseableValue() throws Exception { ResolvedSpringProject resolvedProject = mock(ResolvedSpringProject.class); when(resolvedProject.getGenerations()).thenReturn(null); when(resolvedProject.getSlug()).thenReturn(SpringProjectUtil.SPRING_BOOT); - when(resolvedProject.getReleases()).thenReturn(List.of( + when(resolvedProject.getLatestPatchVersions()).thenReturn(List.of( Version.parse("1.5.6"), Version.parse("1.5.7"), Version.parse("1.5.8"), @@ -393,7 +437,7 @@ void upgradePatchVersionInlay_AlreadyOnLatestPatch() throws Exception { ResolvedSpringProject resolvedProject = mock(ResolvedSpringProject.class); when(resolvedProject.getGenerations()).thenReturn(null); when(resolvedProject.getSlug()).thenReturn(SpringProjectUtil.SPRING_BOOT); - when(resolvedProject.getReleases()).thenReturn(List.of( + when(resolvedProject.getLatestPatchVersions()).thenReturn(List.of( Version.parse("1.5.6"), Version.parse("1.5.7"), Version.parse("1.5.8"), diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersionTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersionTest.java new file mode 100644 index 0000000000..7ab66a8beb --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersionTest.java @@ -0,0 +1,145 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.validation.generations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.junit.jupiter.api.Test; +import org.springframework.ide.vscode.boot.app.BootJavaConfig; +import org.springframework.ide.vscode.boot.java.rewrite.SpringBootPatchUpgrade; +import org.springframework.ide.vscode.boot.validation.generations.json.Generation; +import org.springframework.ide.vscode.boot.validation.generations.json.ResolvedSpringProject; +import org.springframework.ide.vscode.commons.Version; +import org.springframework.ide.vscode.commons.java.IJavaProject; +import org.springframework.ide.vscode.commons.java.IProjectBuild; +import org.springframework.ide.vscode.commons.languageserver.reconcile.DiagnosticSeverityProvider; +import org.springframework.ide.vscode.commons.languageserver.reconcile.ProblemType; +import org.springframework.ide.vscode.commons.protocol.java.ProjectBuild; + +/** + * @author Broadcom + */ +public class UpdateBootVersionTest { + + @Test + void offersSeparateQuickfixesForOssAndEnterprisePatches() throws Exception { + Diagnostic diagnostic = validatePatch(Map.of( + "oss", "3.4.12", + "enterprise", "3.4.16" + ), "3.4.10"); + + assertEquals("Newer patch version of Spring Boot available: 3.4.16 (Enterprise), 3.4.12 (OSS)", + diagnostic.getMessage().getLeft()); + + @SuppressWarnings("unchecked") + List actions = (List) diagnostic.getData(); + assertEquals(3, actions.size()); + assertTrue(actions.get(0).getTitle().contains("3.4.16") && actions.get(0).getTitle().contains("Enterprise")); + assertTrue(actions.get(1).getTitle().contains("3.4.12") && actions.get(1).getTitle().contains("OSS")); + assertTrue(actions.get(2).getTitle().contains("Release Notes") && actions.get(2).getTitle().contains("3.4.12")); + } + + @Test + void ignoresEnterprisePatchNotNewerThanCurrentVersion() throws Exception { + // enterprise patch is behind the project's current version - only the oss upgrade is offered + Diagnostic diagnostic = validatePatch(Map.of( + "oss", "3.4.12", + "enterprise", "3.4.9" + ), "3.4.10"); + + assertEquals("Newer patch version of Spring Boot available: 3.4.12 (OSS)", diagnostic.getMessage().getLeft()); + + @SuppressWarnings("unchecked") + List actions = (List) diagnostic.getData(); + assertEquals(2, actions.size()); + assertTrue(actions.get(0).getTitle().contains("3.4.12") && actions.get(0).getTitle().contains("OSS")); + assertTrue(actions.get(1).getTitle().contains("Release Notes")); + } + + @Test + void noPatchDiagnosticWhenAlreadyOnLatestPatch() throws Exception { + Generation generation = mock(Generation.class); + when(generation.getLatestPatchByType()).thenReturn(toVersionMap(Map.of("oss", "3.4.10"))); + + ResolvedSpringProject bootProject = mock(ResolvedSpringProject.class); + when(bootProject.getLatestPatchVersions()).thenReturn(List.of(Version.parse("3.4.10"))); + when(bootProject.findGeneration(any())).thenReturn(Optional.of(generation)); + + SpringProjectsProvider provider = mock(SpringProjectsProvider.class); + when(provider.getProject(anyString())).thenReturn(bootProject); + + UpdateBootVersion validator = newValidator(provider); + Collection diagnostics = validator.validate(mockMavenProject(), Version.parse("3.4.10")); + + assertEquals(0, diagnostics.size()); + } + + private Diagnostic validatePatch(Map latestPatchByType, String currentVersion) throws Exception { + Generation generation = mock(Generation.class); + when(generation.getLatestPatchByType()).thenReturn(toVersionMap(latestPatchByType)); + + ResolvedSpringProject bootProject = mock(ResolvedSpringProject.class); + when(bootProject.getLatestPatchVersions()).thenReturn(List.of(Version.parse(currentVersion))); + when(bootProject.findGeneration(any())).thenReturn(Optional.of(generation)); + + SpringProjectsProvider provider = mock(SpringProjectsProvider.class); + when(provider.getProject(anyString())).thenReturn(bootProject); + + UpdateBootVersion validator = newValidator(provider); + Collection diagnostics = validator.validate(mockMavenProject(), Version.parse(currentVersion)); + + assertEquals(1, diagnostics.size()); + return diagnostics.iterator().next(); + } + + private static Map toVersionMap(Map raw) { + Map result = new java.util.LinkedHashMap<>(); + raw.forEach((k, v) -> result.put(k, Version.parse(v))); + return result; + } + + private UpdateBootVersion newValidator(SpringProjectsProvider provider) { + DiagnosticSeverityProvider severityProvider = mock(DiagnosticSeverityProvider.class); + when(severityProvider.getDiagnosticSeverity(any(ProblemType.class))).thenReturn(DiagnosticSeverity.Warning); + + BootJavaConfig config = mock(BootJavaConfig.class); + when(config.isUseProjectBuildFileForVersionValidation()).thenReturn(true); + + MavenMetadataProvider mavenMetadataProvider = mock(MavenMetadataProvider.class); + // no Maven metadata available -> forces the generations-based fallback path + + Optional bootUpgradeOpt = Optional.of(mock(SpringBootPatchUpgrade.class)); + + return new UpdateBootVersion(severityProvider, bootUpgradeOpt, provider, mavenMetadataProvider, config); + } + + private IJavaProject mockMavenProject() { + IJavaProject jp = mock(IJavaProject.class); + when(jp.getProjectBuild()).thenReturn(IProjectBuild.create(ProjectBuild.MAVEN_PROJECT_TYPE, null)); + when(jp.getElementName()).thenReturn("test-project"); + when(jp.getLocationUri()).thenReturn(java.net.URI.create("file:///test-project")); + return jp; + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/ProjectGenerationsValidationTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/ProjectGenerationsValidationTest.java index 83b78112d6..6689ba4631 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/ProjectGenerationsValidationTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/ProjectGenerationsValidationTest.java @@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -32,6 +33,7 @@ import org.springframework.ide.vscode.boot.validation.generations.json.Link; import org.springframework.ide.vscode.boot.validation.generations.json.ResolvedSpringProject; import org.springframework.ide.vscode.boot.validation.generations.json.SpringProject; +import org.springframework.ide.vscode.commons.Version; import org.springframework.ide.vscode.project.harness.BootLanguageServerHarness; import org.springframework.ide.vscode.project.harness.ProjectsHarness; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -121,6 +123,12 @@ void testGenerationsFromSample() throws Exception { assertEquals("2019-01-01", generation.getInitialReleaseDate()); assertEquals("2020-01-01", generation.getOssSupportEndDate()); assertEquals("2021-01-01", generation.getCommercialSupportEndDate()); + // enterprise-only patch (OSS support window ended) - not a public upgrade suggestion + assertNull(generation.getLatestPatchVersion()); + + List latestPatchVersions = project.getLatestPatchVersions(); + assertEquals(1, latestPatchVersions.size()); + assertEquals("2.2.13", latestPatchVersions.get(0).toString()); } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/SampleProjectsProvider.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/SampleProjectsProvider.java index 79ccc19528..4222b89d0b 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/SampleProjectsProvider.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/validation/test/SampleProjectsProvider.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2020, 2023 Pivotal, Inc. + * Copyright (c) 2020, 2026 Pivotal, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -84,13 +84,15 @@ private T parse(String json, Class clazz) throws Exception { public static final String SPRING_BOOT_PROJECT_GENERATIONS = "{\n" + " \"_embedded\" : {\n" + " \"generations\" : [ {\n" + " \"name\" : \"1.3.x\",\n" + " \"initialReleaseDate\" : \"2019-01-01\",\n" + " \"ossSupportEndDate\" : \"2020-01-01\",\n" - + " \"commercialSupportEndDate\" : \"2021-01-01\",\n" + " \"_links\" : {\n" + + " \"commercialSupportEndDate\" : \"2021-01-01\",\n" + + " \"latestPatch\" : { \"enterprise\" : \"1.3.8\" },\n" + " \"_links\" : {\n" + " \"self\" : {\n" + " \"href\" : \"https://spring.io/api/projects/spring-boot/generations/1.3.x\"\n" + " },\n" + " \"project\" : {\n" + " \"href\" : \"https://spring.io/api/projects/spring-boot\"\n" + " }\n" + " }\n" + " }, {\n" + " \"name\" : \"2.2.x\",\n" + " \"initialReleaseDate\" : \"2020-01-01\",\n" + " \"ossSupportEndDate\" : \"2021-01-01\",\n" - + " \"commercialSupportEndDate\" : \"2022-01-01\",\n" + " \"_links\" : {\n" + + " \"commercialSupportEndDate\" : \"2022-01-01\",\n" + + " \"latestPatch\" : { \"oss\" : \"2.2.13\" },\n" + " \"_links\" : {\n" + " \"self\" : {\n" + " \"href\" : \"https://spring.io/api/projects/spring-boot/generations/2.2.x\"\n" + " },\n" + " \"project\" : {\n" + " \"href\" : \"https://spring.io/api/projects/spring-boot\"\n" From 98c08317875a5a8e2a02117b01bc7b1cfdba0b62 Mon Sep 17 00:00:00 2001 From: BoykoAlex Date: Mon, 28 Sep 2026 19:28:25 -0700 Subject: [PATCH 2/2] Adjustments Signed-off-by: BoykoAlex --- .../ide/vscode/boot/maven/PomInlayHintHandler.java | 5 +---- .../validation/generations/UpdateBootVersion.java | 9 +-------- .../generations/VersionValidationUtils.java | 9 --------- .../validation/generations/json/Generation.java | 13 ++----------- .../generations/json/ResolvedSpringProject.java | 5 +---- 5 files changed, 5 insertions(+), 36 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java index 767bca8ac1..1c9e309e8d 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/maven/PomInlayHintHandler.java @@ -155,8 +155,7 @@ public List handle(TextDocument doc, Range range, CancelChecker cance } if (versions != null) { - // type is "oss"/"enterprise" when sourced from a generation's latestPatch map, - // or "" for the generic Maven-metadata/single-candidate case. + // type is "oss"/"enterprise", or "" for the generic single-candidate case. Optional currentGeneration = usedMavenMetadata || genProject == null ? Optional.empty() : genProject.findGeneration(currentVersion); @@ -165,8 +164,6 @@ public List handle(TextDocument doc, Range range, CancelChecker cance if (currentGeneration.isPresent()) { patchCandidatesByType = currentGeneration.get().getLatestPatchByType(); } else { - // Maven metadata and "generation not found" both have no oss/enterprise - // concept - just the single, generic candidate from the sorted version list. patchCandidatesByType = versions.getNewerLatestPatchRelease(currentVersion) .map(latest -> Map.of("", latest)) .orElse(Map.of()); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java index 65cb0fbe2d..34ff56d9c7 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/UpdateBootVersion.java @@ -87,8 +87,6 @@ public Collection validate(IJavaProject javaProject, Version javaPro if (currentGeneration.isPresent()) { patchCandidatesByType = currentGeneration.get().getLatestPatchByType(); } else { - // Maven metadata and "generation not found" both have no oss/enterprise concept - - // just the single, generic candidate from the sorted version list. patchCandidatesByType = versions.getNewerLatestPatchRelease(javaProjectVersion) .map(latest -> Map.of("", latest)) .orElse(Map.of()); @@ -134,12 +132,7 @@ private Optional validateMinorVersion(IJavaProject javaProject, Vers } /** - * A patch upgrade can have more than one candidate at once - e.g. a publicly available - * {@code oss} one and a commercial-only {@code enterprise} one when sourced from a - * generation's {@code latestPatch}, or a single untyped ({@code ""}) one when sourced - * from the project's resolvable Maven repository. Offers a separate quickfix per - * candidate that is newer than the project's current version, so the user can pick - * whichever they actually have access to. + * One quickfix per candidate newer than the current version (e.g. oss + enterprise). */ private Optional validatePatchVersions(IJavaProject javaProject, Version javaProjectVersion, Map candidatesByType) { List> newerCandidates = candidatesByType.entrySet().stream() diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java index 0555989301..d7464544c3 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/VersionValidationUtils.java @@ -11,11 +11,8 @@ package org.springframework.ide.vscode.boot.validation.generations; import java.sql.Date; -import java.util.List; import org.springframework.ide.vscode.boot.validation.generations.json.Generation; -import org.springframework.ide.vscode.boot.validation.generations.json.ResolvedSpringProject; -import org.springframework.ide.vscode.commons.Version; public class VersionValidationUtils { @@ -37,12 +34,6 @@ public static boolean isCommercialValid(Generation gen) { return false; } - public static Version getLatestSupportedRelease(ResolvedSpringProject springProject) - throws Exception { - List rls = springProject.getReleases(); - return rls.isEmpty() ? null : rls.get(rls.size() - 1); - } - /** * Human-readable label for a {@code Generation.latestPatch} entry key * ({@code oss} or {@code enterprise}). diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java index 2fd2998d60..5c290a564c 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/Generation.java @@ -73,12 +73,7 @@ public void setLatestPatch(Map latestPatch) { } /** - * The latest publicly-available (OSS) patch release for this generation, i.e. - * the {@code oss} value of the {@code latestPatch} object. Returns {@code null} - * if the generation's OSS support window has ended - at that point {@code latestPatch} - * only carries an {@code enterprise} version, which is published to Broadcom's - * commercial repository rather than public Maven Central, so it isn't a valid - * upgrade suggestion for the general public. + * OSS-only latest patch; {@code null} once OSS support has ended (enterprise-only from then on). */ public Version getLatestPatchVersion() { if (latestPatch == null) { @@ -88,11 +83,7 @@ public Version getLatestPatchVersion() { } /** - * The {@code latestPatch} entries parsed into {@link Version}s, keyed by - * support type ({@code oss} or {@code enterprise}). Unlike - * {@link #getLatestPatchVersion()}, this exposes every known patch (including - * commercial-only ones) so callers can decide whether/how to offer each as an - * upgrade option. + * All {@code latestPatch} entries, keyed by type ({@code oss}/{@code enterprise}). */ public Map getLatestPatchByType() { Map result = new LinkedHashMap<>(); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java index 52bd88f43f..3bf4975cfc 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/json/ResolvedSpringProject.java @@ -52,10 +52,7 @@ public List getGenerations() throws Exception { } /** - * Latest patch version of each generation, e.g. one entry per major.minor line - * (including generations that are no longer within their support window). - * Unlike {@link #getReleases()}, this covers every known generation, not just - * the currently supported ones. + * OSS latest patch per generation, including generations no longer in support. */ public List getLatestPatchVersions() throws Exception { return getGenerations().stream()