diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/build.gradle.kts b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/build.gradle.kts new file mode 100644 index 00000000000..e1a7df7b7b9 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/build.gradle.kts @@ -0,0 +1,15 @@ +dependencies { + implementation(project(":azure-intellij-plugin-lib")) + implementation(project(":azure-intellij-plugin-lib-java")) + // runtimeOnly project(path: ":azure-intellij-plugin-lib", configuration: "instrumentedJar") + implementation("com.microsoft.azure:azure-toolkit-ide-common-lib") + intellijPlatform { + // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. + bundledPlugin("com.intellij.java") + bundledPlugin("org.jetbrains.idea.maven") + // bundledPlugin("org.jetbrains.idea.maven.model") + bundledPlugin("com.intellij.gradle") + // Copilot plugin for Java upgrade integration + plugin("com.github.copilot:1.5.59-243") + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/common/AppModPluginInstaller.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/common/AppModPluginInstaller.java new file mode 100644 index 00000000000..1683423bbd5 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/common/AppModPluginInstaller.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.common; + +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.openapi.extensions.PluginId; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.PluginsAdvertiser; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nonnull; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Utility class for managing App Modernization plugin installation. + * This centralizes all plugin detection and installation logic to avoid code duplication + * between MigrateToAzureNode and MigrateToAzureAction. + */ +@Slf4j +public class AppModPluginInstaller { + private static final String PLUGIN_ID = "com.github.copilot.appmod"; + private static final String COPILOT_PLUGIN_ID = "com.github.copilot"; + public static final String TO_INSTALL_APP_MODE_PLUGIN = " (Install Github Copilot app modernization)"; + private AppModPluginInstaller() { + // Utility class - prevent instantiation + } + + /** + * Checks if the App Modernization plugin is installed and enabled. + */ + public static boolean isAppModPluginInstalled() { + try { + final PluginId pluginId = PluginId.getId(PLUGIN_ID); + final IdeaPluginDescriptor plugin = PluginManagerCore.getPlugin(pluginId); + final boolean result = plugin != null && plugin.isEnabled(); + log.debug("[AppModPluginInstaller] isAppModPluginInstalled: {}", result); + return result; + } catch (Exception e) { + log.error("[AppModPluginInstaller] Failed to check AppMod plugin status", e); + return false; + } + } + + /** + * Checks if GitHub Copilot plugin is installed and enabled. + */ + public static boolean isCopilotInstalled() { + try { + final PluginId pluginId = PluginId.getId(COPILOT_PLUGIN_ID); + final IdeaPluginDescriptor plugin = PluginManagerCore.getPlugin(pluginId); + final boolean result = plugin != null && plugin.isEnabled(); + log.debug("[AppModPluginInstaller] isCopilotInstalled: {}", result); + return result; + } catch (Exception e) { + log.error("[AppModPluginInstaller] Failed to check Copilot plugin status", e); + return false; + } + } + + /** + * Detects if running in development mode (runIde task). + * This helps determine whether to show restart options or dev-mode instructions. + */ + public static boolean isRunningInDevMode() { + try { + final PluginId azureToolkitId = PluginId.getId("com.microsoft.tooling.msservices.intellij.azure"); + final IdeaPluginDescriptor descriptor = PluginManagerCore.getPlugin(azureToolkitId); + if (descriptor != null) { + final String path = descriptor.getPluginPath().toString(); + final boolean result = path.contains("build") || path.contains("sandbox") || path.contains("out"); + log.debug("[AppModPluginInstaller] isRunningInDevMode: {}, path: {}", result, path); + return result; + } + } catch (Exception ex) { + log.error("[AppModPluginInstaller] Failed to check dev mode status", ex); + } + return false; + } + + /** + * Shows a confirmation dialog for plugin installation. + * + * @param project The current project + * @param forUpgrade true for "upgrade" scenario, false for "migrate to Azure" scenario + * @param onConfirm Callback to execute when user confirms installation (if null, calls installPlugin directly) + */ + public static void showInstallConfirmation(@Nonnull Project project, boolean forUpgrade, @Nonnull Runnable onConfirm) { + final boolean copilotInstalled = isCopilotInstalled(); + final String action = forUpgrade ? "upgrade" : "migration"; + log.debug("[AppModPluginInstaller] showInstallConfirmation - forUpgrade: {}, copilotInstalled: {}", forUpgrade, copilotInstalled); + + final String title = copilotInstalled + ? "Install Github Copilot app modernization" + : "Install GitHub Copilot and app modernization"; + + final String message; + if (copilotInstalled) { + message = forUpgrade + ? "Install this plugin to upgrade your apps with Copilot." + : "Install this plugin to automate migrating your apps to Azure with Copilot."; + } else { + message = forUpgrade + ? "To upgrade your apps, you'll need two plugins: GitHub Copilot and app modernization." + : "To migrate to Azure, you'll need two plugins: GitHub Copilot and app modernization."; + } + + if (Messages.showOkCancelDialog(project, message, title, "Install", "Cancel", Messages.getQuestionIcon()) == Messages.OK) { + log.info("[AppModPluginInstaller] User confirmed plugin installation for {}", action); + AppModUtils.logTelemetryEvent("plugin." + action + ".install-confirmed"); + onConfirm.run(); + } else { + log.info("[AppModPluginInstaller] User cancelled plugin installation for {}", action); + AppModUtils.logTelemetryEvent("plugin." + action + ".install-cancelled"); + } + } + + + /** + * Installs the App Modernization plugin. + * IntelliJ platform will automatically install Copilot as a dependency if AppMod declares it. + * + * @param project The current project + * @param forUpgrade true for "upgrade" scenario, false for "migrate to Azure" scenario + */ + public static void installPlugin(@Nonnull Project project, boolean forUpgrade) { + log.debug("[AppModPluginInstaller] installPlugin - forUpgrade: {}", forUpgrade); + if (isAppModPluginInstalled()) { + log.debug("[AppModPluginInstaller] installPlugin - skipping, already installed"); + return; + } + + // Only pass AppMod ID - IntelliJ will automatically install Copilot as dependency + final Set pluginsToInstall = new LinkedHashSet<>(); + pluginsToInstall.add(PluginId.getId(PLUGIN_ID)); + + final String source = forUpgrade ? "upgrade" : "migration"; + log.info("[AppModPluginInstaller] Starting plugin installation via PluginsAdvertiser"); + PluginsAdvertiser.installAndEnable( + project, + pluginsToInstall, + true, // showDialog + true, // selectAllInDialog - pre-select all plugins + null, // modalityState + () -> { + log.info("[AppModPluginInstaller] Plugin installation completed"); + AppModUtils.logTelemetryEvent("plugin." + source + ".install-complete"); + } + ); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/IMigrateOptionProvider.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/IMigrateOptionProvider.java new file mode 100644 index 00000000000..a82fb4277f7 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/IMigrateOptionProvider.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javamigration; + +import com.intellij.openapi.project.Project; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Extension point interface for providing migration nodes. + * + * Implementations of this interface will be discovered via IntelliJ's extension point mechanism + * and used to dynamically construct the migration options tree in: + * - Service Explorer (MigrateToAzureNode) + * - Context Menu (MigrateToAzureAction) + * - Project Explorer (MigrateToAzureFacetNode) + * + * Example implementation: + *
+ * public class MyMigrationProvider implements IMigrateOptionProvider {
+ *     @Override
+ *     public List<MigrateNodeData> createNodeData(@Nonnull Project project) {
+ *         return List.of(
+ *             MigrateNodeData.builder("My Migration Option")
+ *                 .onClick(() -> performMigration(project))
+ *                 .build()
+ *         );
+ *     }
+ * }
+ * 
+ * + * Registration in plugin.xml: + *
+ * <extensions defaultExtensionNs="com.microsoft.tooling.msservices.intellij.azure">
+ *     <migrateOptionProvider implementation="your.package.MyMigrationProvider"/>
+ * </extensions>
+ * 
+ */ +public interface IMigrateOptionProvider { + + /** + * Creates migration node data for the Migrate to Azure section. + * + * This method is called each time the migration menu/tree is constructed. + * The returned list can contain multiple MigrateNodeData instances, + * each representing a single action or a group of options. + * + * @param project The current IntelliJ project + * @return A list of MigrateNodeData instances representing the migration option(s) + */ + @Nonnull + List createNodeData(@Nonnull Project project); + + /** + * Returns the priority/order of this node provider. + * Nodes will be displayed in ascending order of priority. + * Lower numbers appear first. + * + * @return Priority value (default: 100) + */ + default int getPriority() { + return 100; + } + + /** + * Determines whether this provider should create a node. + * Can be used to conditionally show/hide migration options based on context. + * + * @param project The current IntelliJ project + * @return true if this provider should contribute a node, false otherwise + */ + default boolean isApplicable(@Nonnull Project project) { + return true; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateNodeData.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateNodeData.java new file mode 100644 index 00000000000..1733fddd1aa --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateNodeData.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javamigration; + +import lombok.Getter; +import lombok.Setter; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Unified data structure for migration nodes. + * This class is used by MigrateToAzureNode, MigrateToAzureAction, and MigrateToAzureFacetNode. + * + * Features: + * - Basic properties: label, description (used as menu description and tooltip) + * - Click handling for both leaf and parent nodes + * - Static children or lazy-loaded children via childrenLoader + * + * Lazy Loading: + * Provider can declare lazy loading intent via childrenLoader. Each consumer handles it based on capability: + * - Service Explorer: Supports lazy loading → uses Node.withChildrenLoadLazily(true) + * - Project Explorer: Native lazy loading → buildChildren() calls loader + * - Action menus: No lazy loading → calls loader.get() immediately + * + * Usage examples: + * + * 1. Simple leaf node with click action: + *
+ * MigrateNodeData.builder("Option A")
+ *     .onClick(e -> doSomething())
+ *     .build();
+ * 
+ * + * 2. Parent node with static children: + *
+ * MigrateNodeData.builder("Parent")
+ *     .addChild(childNode1)
+ *     .addChild(childNode2)
+ *     .build();
+ * 
+ * + * 3. Parent node with lazy-loaded children: + *
+ * MigrateNodeData.builder("Lazy Parent")
+ *     .childrenLoader(() -> loadChildrenFromServer())
+ *     .build();
+ * 
+ */ +@Getter +public class MigrateNodeData { + + // ==================== Basic Properties ==================== + + /** + * Display label for the node. + */ + @Nonnull + private final String label; + + /** + * Description text. Used as: + * - Menu item description in MigrateToAzureAction + * - Tooltip in MigrateToAzureNode and MigrateToAzureFacetNode + */ + @Nullable + private String description; + + // ==================== State ==================== + + /** + * Whether the node is enabled (clickable). + */ + @Setter + private boolean enabled = true; + + /** + * Whether the node is visible. + */ + @Setter + private boolean visible = true; + + // ==================== Click Handling ==================== + + /** + * Click handler. Can be set on any node (leaf or parent). + * For parent nodes in menus, this may be triggered by a specific action. + */ + @Nullable + private Consumer clickHandler; + + /** + * Double-click handler. Useful for tree views where single-click selects + * and double-click performs action. + */ + @Nullable + private Consumer doubleClickHandler; + + // ==================== Children ==================== + + /** + * Static list of child nodes. + */ + @Nonnull + private final List children = new ArrayList<>(); + + /** + * Lazy loader for children. If set, consumers that support lazy loading + * will use this to load children on demand. Consumers without lazy loading + * support will call this immediately. + * + * Note: This is just a declaration of intent. MigrateNodeData does NOT + * manage loading state - each consumer handles that according to its capability. + */ + @Nullable + private Supplier> childrenLoader; + + // ==================== Constructor ==================== + + private MigrateNodeData(@Nonnull String label) { + this.label = label; + } + + // ==================== Builder ==================== + + /** + * Creates a new builder for MigrateNodeData. + * + * @param label The display label for the node + * @return A new builder instance + */ + public static Builder builder(@Nonnull String label) { + return new Builder(label); + } + + // ==================== Methods ==================== + + /** + * Checks if this node has children (static or via loader). + */ + public boolean hasChildren() { + return !children.isEmpty() || childrenLoader != null; + } + + /** + * Gets the static children list. + * For lazy-loaded children, use getChildrenLoader() instead. + */ + @Nonnull + public List getChildren() { + return children; + } + + /** + * Gets the children loader for lazy loading. + * Consumers should check this and handle according to their capability. + */ + @Nullable + public Supplier> getChildrenLoader() { + return childrenLoader; + } + + /** + * Checks if this node uses lazy loading for children. + */ + public boolean isLazyLoading() { + return childrenLoader != null; + } + + /** + * Triggers the click handler. + * + * @param event The event object (can be AnActionEvent, MouseEvent, or null) + */ + public void click(@Nullable Object event) { + if (clickHandler != null && enabled) { + clickHandler.accept(event); + } + } + + /** + * Triggers the double-click handler. + * Falls back to click handler if double-click is not set. + * + * @param event The event object + */ + public void doubleClick(@Nullable Object event) { + if (doubleClickHandler != null && enabled) { + doubleClickHandler.accept(event); + } else { + click(event); + } + } + + /** + * Checks if this node has a click handler. + */ + public boolean hasClickHandler() { + return clickHandler != null; + } + + /** + * Adds a child node dynamically. + */ + public void addChild(@Nonnull MigrateNodeData child) { + children.add(child); + } + + /** + * Removes a child node. + */ + public void removeChild(@Nonnull MigrateNodeData child) { + children.remove(child); + } + + // ==================== Builder Class ==================== + + public static class Builder { + private final MigrateNodeData data; + + private Builder(@Nonnull String label) { + this.data = new MigrateNodeData(label); + } + + /** + * Sets the description (used as menu description and tooltip). + */ + public Builder description(@Nullable String description) { + data.description = description; + return this; + } + + /** + * Sets the enabled state. + */ + public Builder enabled(boolean enabled) { + data.enabled = enabled; + return this; + } + + /** + * Sets the visible state. + */ + public Builder visible(boolean visible) { + data.visible = visible; + return this; + } + + /** + * Sets the click handler. + */ + public Builder onClick(@Nullable Consumer handler) { + data.clickHandler = handler; + return this; + } + + /** + * Sets the click handler (no-arg version). + */ + public Builder onClick(@Nullable Runnable handler) { + if (handler != null) { + data.clickHandler = e -> handler.run(); + } + return this; + } + + /** + * Sets the double-click handler. + */ + public Builder onDoubleClick(@Nullable Consumer handler) { + data.doubleClickHandler = handler; + return this; + } + + /** + * Sets the double-click handler (no-arg version). + */ + public Builder onDoubleClick(@Nullable Runnable handler) { + if (handler != null) { + data.doubleClickHandler = e -> handler.run(); + } + return this; + } + + /** + * Adds a child node. + */ + public Builder addChild(@Nonnull MigrateNodeData child) { + data.children.add(child); + return this; + } + + /** + * Adds multiple child nodes. + */ + public Builder addChildren(@Nonnull List children) { + data.children.addAll(children); + return this; + } + + /** + * Sets the children loader for lazy loading. + * Consumers that support lazy loading will use this to load children on demand. + * Consumers without lazy loading support will call this immediately. + * + * @param loader Supplier that returns the list of child nodes + */ + public Builder childrenLoader(@Nullable Supplier> loader) { + data.childrenLoader = loader; + return this; + } + + /** + * Builds the MigrateNodeData instance. + */ + public MigrateNodeData build() { + return data; + } + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateToAzureAction.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateToAzureAction.java new file mode 100644 index 00000000000..ff469c78141 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateToAzureAction.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javamigration; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.ActionGroup; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.actionSystem.ex.ActionUtil; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.extensions.ExtensionPointName; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModPanelHelper; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Unified ActionGroup for "Migrate to Azure" functionality. + * Handles all three states: + * 1. Plugin NOT installed - direct click triggers installation + * 2. Plugin installed but no migration options - shows "Open App Mod Panel" action + * 3. Plugin installed with migration options - sub-menu shows migration options + * + * Data is preloaded by MigrationStatePreloader when project opens. + * If data is not ready yet, shows "Open App Mod Panel" as fallback. + */ +@Slf4j +public class MigrateToAzureAction extends ActionGroup { + private static final ExtensionPointName MIGRATION_PROVIDERS = + ExtensionPointName.create("com.microsoft.tooling.msservices.intellij.azure.migrateOptionProvider"); + static final Key STATE_KEY = Key.create("azure.migrate.action.state"); + static final Key LOADING_KEY = Key.create("azure.migrate.action.loading"); + + enum State { NOT_INSTALLED, LOADING, NO_OPTIONS, HAS_OPTIONS } + + static class MigrationState { + final State state; + final List nodes; + + MigrationState(State state, List nodes) { + this.state = state; + this.nodes = nodes; + } + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + /** + * Gets migration state for the project. + * State is preloaded by MigrationStatePreloader on project open. + * Returns LOADING state if data is not ready yet, and triggers async loading. + */ + private MigrationState getOrComputeState(Project project) { + // Fast path: if plugin not installed, return immediately + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + log.debug("[MigrateToAzureAction] Plugin not installed, returning NOT_INSTALLED"); + return new MigrationState(State.NOT_INSTALLED, List.of()); + } + + // Check if we have preloaded state + MigrationState state = project.getUserData(STATE_KEY); + if (state != null) { + return state; + } + + // State not ready yet - trigger async loading if not already loading + Boolean isLoading = project.getUserData(LOADING_KEY); + if (!Boolean.TRUE.equals(isLoading)) { + project.putUserData(LOADING_KEY, Boolean.TRUE); + log.debug("[MigrateToAzureAction] State not ready, triggering async load"); + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + final MigrationState computedState = computeState(project); + if (computedState != null) { + project.putUserData(STATE_KEY, computedState); + log.debug("[MigrateToAzureAction] Async load completed, state: {}", computedState.state); + } + } finally { + project.putUserData(LOADING_KEY, Boolean.FALSE); + } + }); + } + + log.debug("[MigrateToAzureAction] State not ready yet, returning LOADING"); + return new MigrationState(State.LOADING, List.of()); + } + + /** + * Computes migration state by calling providers. + * Called by MigrationStatePreloader during project startup. + */ + static MigrationState computeState(Project project) { + final long startTime = System.currentTimeMillis(); + log.debug("[MigrateToAzureAction] computeState - start"); + try { + final long providerStartTime = System.currentTimeMillis(); + final List nodes = MIGRATION_PROVIDERS.getExtensionList().stream() + .filter(provider -> provider.isApplicable(project)) + .sorted(Comparator.comparingInt(IMigrateOptionProvider::getPriority)) + .flatMap(provider -> provider.createNodeData(project).stream()) + .filter(MigrateNodeData::isVisible) + .collect(Collectors.toList()); + + log.debug("[MigrateToAzureAction] computeState - loaded {} nodes, provider call took {}ms, total {}ms", + nodes.size(), System.currentTimeMillis() - providerStartTime, System.currentTimeMillis() - startTime); + + if (nodes.isEmpty()) { + AppModUtils.logTelemetryEvent("action.no-tasks"); + } + + return new MigrationState( + nodes.isEmpty() ? State.NO_OPTIONS : State.HAS_OPTIONS, + nodes + ); + } catch (Exception e) { + log.error("[MigrateToAzureAction] Failed to compute migration state, took {}ms", System.currentTimeMillis() - startTime, e); + // Return null to indicate failure - caller should not cache this result + return null; + } + } + + @Override + public void update(@NotNull AnActionEvent e) { + final long startTime = System.currentTimeMillis(); + final Project project = e.getProject(); + if (project == null) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + + final MigrationState migrationState = getOrComputeState(project); + log.debug("[MigrateToAzureAction] update - state: {}, took {}ms", migrationState.state, System.currentTimeMillis() - startTime); + + // Common settings for all states + e.getPresentation().setPopupGroup(true); + e.getPresentation().putClientProperty(ActionUtil.ALWAYS_VISIBLE_GROUP, true); + + switch (migrationState.state) { + case NOT_INSTALLED: + final boolean copilotInstalled = AppModPluginInstaller.isCopilotInstalled(); + e.getPresentation().setText(copilotInstalled + ? "Migrate to Azure (Install Github Copilot app modernization)" + : "Migrate to Azure (Install GitHub Copilot and app modernization)"); + e.getPresentation().setPerformGroup(true); + e.getPresentation().putClientProperty(ActionUtil.SUPPRESS_SUBMENU, true); + break; + case LOADING: + case NO_OPTIONS: + e.getPresentation().setText("Migrate to Azure (Open GitHub Copilot app modernization)"); + e.getPresentation().setPerformGroup(true); + e.getPresentation().putClientProperty(ActionUtil.SUPPRESS_SUBMENU, true); + break; + case HAS_OPTIONS: + e.getPresentation().setText("Migrate to Azure"); + e.getPresentation().setPerformGroup(false); + e.getPresentation().putClientProperty(ActionUtil.SUPPRESS_SUBMENU, false); + break; + } + e.getPresentation().setEnabledAndVisible(true); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + final Project project = e.getProject(); + if (project == null) { + return; + } + + final MigrationState migrationState = getOrComputeState(project); + log.debug("[MigrateToAzureAction] actionPerformed - state: {}", migrationState.state); + + switch (migrationState.state) { + case NOT_INSTALLED: + log.info("[MigrateToAzureAction] Install click triggered"); + AppModUtils.logTelemetryEvent("action.click-install"); + AppModPluginInstaller.showInstallConfirmation(project, false, + () -> AppModPluginInstaller.installPlugin(project, false)); + break; + case LOADING: + case NO_OPTIONS: + log.info("[MigrateToAzureAction] Opening AppMod panel (state: {})", migrationState.state); + AppModPanelHelper.openAppModPanel(project, "action"); + break; + case HAS_OPTIONS: + // Handled by popup menu + break; + } + } + + @Override + public AnAction @NotNull [] getChildren(@Nullable AnActionEvent e) { + final long startTime = System.currentTimeMillis(); + try { + if (e == null) { + return AnAction.EMPTY_ARRAY; + } + + final Project project = e.getProject(); + if (project == null) { + return AnAction.EMPTY_ARRAY; + } + + final MigrationState migrationState = getOrComputeState(project); + + if (migrationState.state == State.HAS_OPTIONS) { + final AnAction[] result = migrationState.nodes.stream() + .map(this::convertNodeToAction) + .toArray(AnAction[]::new); + log.debug("[MigrateToAzureAction] getChildren - returned {} actions, took {}ms", result.length, System.currentTimeMillis() - startTime); + return result; + } + + log.debug("[MigrateToAzureAction] getChildren - no options, took {}ms", System.currentTimeMillis() - startTime); + return AnAction.EMPTY_ARRAY; + } catch (Exception ex) { + log.error("[MigrateToAzureAction] Failed to get children, took {}ms", System.currentTimeMillis() - startTime, ex); + return AnAction.EMPTY_ARRAY; + } + } + + private AnAction convertNodeToAction(MigrateNodeData nodeData) { + if (nodeData.hasChildren()) { + final DefaultActionGroup subgroup = new DefaultActionGroup(nodeData.getLabel(), true); + subgroup.getTemplatePresentation().setIcon(AllIcons.Vcs.Changelist); + + try { + final long loadStartTime = System.currentTimeMillis(); + final List children = nodeData.isLazyLoading() + ? nodeData.getChildrenLoader().get() + : nodeData.getChildren(); + log.debug("[MigrateToAzureAction] convertNodeToAction - loaded {} children for '{}', lazy={}, took {}ms", + children.size(), nodeData.getLabel(), nodeData.isLazyLoading(), System.currentTimeMillis() - loadStartTime); + + for (MigrateNodeData child : children) { + if (child.isVisible()) { + subgroup.add(convertNodeToAction(child)); + } + } + } catch (Exception e) { + log.error("[MigrateToAzureAction] Failed to load children for node: {}", nodeData.getLabel(), e); + } + return subgroup; + } else { + return new AnAction(nodeData.getLabel(), nodeData.getDescription(), AllIcons.Vcs.Changelist) { + @Override + public void update(@NotNull AnActionEvent e) { + e.getPresentation().setEnabled(nodeData.isEnabled()); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + AppModUtils.logTelemetryEvent("action.click-task", Map.of("label", nodeData.getLabel())); + nodeData.click(e); + } + }; + } + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateToAzureNode.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateToAzureNode.java new file mode 100644 index 00000000000..17c919f0f80 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrateToAzureNode.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javamigration; + +import com.intellij.openapi.extensions.ExtensionPointName; +import com.intellij.openapi.project.Project; +import lombok.extern.slf4j.Slf4j; +import com.microsoft.azure.toolkit.ide.common.component.Node; +import com.microsoft.azure.toolkit.ide.common.icon.AzureIcon; +import com.microsoft.azure.toolkit.ide.common.icon.AzureIcons; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModPanelHelper; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.intellij.appmod.utils.Constants; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.lib.common.action.Action; +import com.microsoft.azure.toolkit.lib.common.action.ActionGroup; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Service Explorer node for "Migrate to Azure" functionality. + * This node extends the azure-toolkit-ide-common-lib Node class to integrate with the Service Explorer tree. + * + * State is computed on initialization and can be refreshed via refresh() method. + */ +@Slf4j +public final class MigrateToAzureNode extends Node { + private static final ExtensionPointName childProviders = + ExtensionPointName.create("com.microsoft.tooling.msservices.intellij.azure.migrateOptionProvider"); + + private final Project project; + + private static final AzureIcon APP_MOD_ICON = AzureIcon.builder().iconPath(Constants.ICON_APPMOD_PATH).build(); + private static final AzureIcon CHANGELIST_ICON = AzureIcon.builder().iconPath("/icons/changelist").build(); + + public MigrateToAzureNode(Project project) { + super("Migrate to Azure"); + this.project = project; + log.debug("[MigrateToAzureNode] Creating node for project: {}", project.getName()); + withIcon(APP_MOD_ICON); + + // Add refresh action to context menu (only once in constructor) + withActions(new ActionGroup( + new Action<>(Action.Id.of("user/appmod.refresh_migrate_node")) + .withLabel("Refresh") + .withIcon(AzureIcons.Action.REFRESH.getIconPath()) + .withHandler((v, e) -> this.refresh()) + .withAuthRequired(false) + )); + + // Use addChildren with a function so it rebuilds on refresh + addChildren(data -> buildChildNodes()); + + initializeNode(); + } + + public void initializeNode() { + log.debug("[MigrateToAzureNode] initializeNode - appModInstalled: {}", AppModPluginInstaller.isAppModPluginInstalled()); + // Clear previous state + clearClickHandlers(); + withDescription(""); + + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + showNotInstalled(); + } + // Don't call showMigrationOptions() here - let buildChildNodes() handle it + // This avoids double loading of extension point data + } + + /** + * Refreshes the node by re-computing migration options. + * Called by RefreshMigrateToAzureAction from context menu. + */ + public void refresh() { + log.debug("[MigrateToAzureNode] refresh called"); + AppModUtils.logTelemetryEvent("node.refresh"); + refreshChildren(); // This rebuilds children from addChildren function + } + + public Project getProject() { + return project; + } + + private void showNotInstalled() { + final boolean copilotInstalled = AppModPluginInstaller.isCopilotInstalled(); + log.debug("[MigrateToAzureNode] showNotInstalled - copilotInstalled: {}", copilotInstalled); + + // Dynamic description based on what needs to be installed + final String description = copilotInstalled + ? "Install Github Copilot app modernization" + : "Install GitHub Copilot and app modernization"; + withDescription(description); + + onClicked(e -> { + log.info("[MigrateToAzureNode] Install click triggered"); + AppModUtils.logTelemetryEvent("node.click-install"); + AppModPluginInstaller.showInstallConfirmation(project, false, () -> AppModPluginInstaller.installPlugin(project, false)); + }); + } + + /** + * Load migration options from extension points. + */ + private List loadMigrationNodeData() { + log.debug("[MigrateToAzureNode] loadMigrationNodeData - loading extension points"); + try { + final List nodes = childProviders.getExtensionList().stream() + .filter(provider -> provider.isApplicable(project)) + .sorted(Comparator.comparingInt(IMigrateOptionProvider::getPriority)) + .flatMap(provider -> provider.createNodeData(project).stream()) + .filter(MigrateNodeData::isVisible) + .collect(Collectors.toList()); + if (nodes.isEmpty()) { + AppModUtils.logTelemetryEvent("node.no-tasks"); + } + return nodes; + } catch (Exception e) { + log.error("[MigrateToAzureNode] Failed to load migration node data", e); + return List.of(); + } + } + + /** + * Build child nodes - called by Node framework on refresh. + * Also updates description and click handler based on data. + */ + private List> buildChildNodes() { + log.debug("[MigrateToAzureNode] buildChildNodes - appModInstalled: {}", AppModPluginInstaller.isAppModPluginInstalled()); + try { + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + log.debug("[MigrateToAzureNode] buildChildNodes - returning empty (plugin not installed)"); + return List.of(); + } + + final List nodeDataList = loadMigrationNodeData(); + log.debug("[MigrateToAzureNode] buildChildNodes - loaded {} nodes", nodeDataList.size()); + + // Update description and click handler based on data + clearClickHandlers(); + if (nodeDataList.isEmpty()) { + log.debug("[MigrateToAzureNode] buildChildNodes - no migration options, setting click to open panel"); + withDescription("Open GitHub Copilot app modernization"); + onClicked(e -> { + log.info("[MigrateToAzureNode] Opening AppMod panel (no options)"); + AppModPanelHelper.openAppModPanel(project, "node"); + }); + } else { + withDescription(""); + } + + return nodeDataList.stream() + .map(this::convertToNode) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("[MigrateToAzureNode] Failed to build child nodes", e); + return List.of(); + } + } + + /** + * Converts MigrateNodeData to Node for Service Explorer compatibility. + */ + private Node convertToNode(MigrateNodeData data) { + Node node = new Node<>(data); + + // Set basic properties + node.withLabel(d -> d.getLabel()); + // Use Changelist icon for child nodes + node.withIcon(CHANGELIST_ICON); + if (data.getDescription() != null) { + node.withTips(d -> d.getDescription()); + } + + // Set click handler + if (data.hasClickHandler()) { + node.onClicked(d -> { + AppModUtils.logTelemetryEvent("node.click-task", Map.of("label", data.getLabel())); + data.click(null); + }); + } + + // Handle children - lazy or static + if (data.isLazyLoading()) { + // Lazy loading: use Node's native lazy loading mechanism + node.withChildrenLoadLazily(true); + node.addChildren( + d -> d.getChildrenLoader().get(), + (childData, parent) -> convertToNode(childData) + ); + } else if (data.hasChildren()) { + // Static children: add directly + for (MigrateNodeData childData : data.getChildren()) { + node.addChild(convertToNode(childData)); + } + } + + return node; + } + + @Override + public synchronized void refreshView() { + super.refreshView(); + refreshChildrenLater(false); + } + + public static boolean isPluginInstalled() { + return AppModPluginInstaller.isAppModPluginInstalled(); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrationStatePreloader.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrationStatePreloader.java new file mode 100644 index 00000000000..d98caaa5c56 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javamigration/MigrationStatePreloader.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javamigration; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Preloads migration state when project opens. + * This ensures that when user opens the context menu, the data is already available. + */ +@Slf4j +public class MigrationStatePreloader implements ProjectActivity { + + @Nullable + @Override + public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + // Only preload if AppMod plugin is installed + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + log.info("[MigrationStatePreloader] AppMod plugin not installed, skipping preload"); + return Unit.INSTANCE; + } + + // Check if already loading or loaded + if (Boolean.TRUE.equals(project.getUserData(MigrateToAzureAction.LOADING_KEY)) || + project.getUserData(MigrateToAzureAction.STATE_KEY) != null) { + log.debug("[MigrationStatePreloader] Already loading or loaded, skipping"); + return Unit.INSTANCE; + } + + // Mark as loading to prevent duplicate loading from MigrateToAzureAction + project.putUserData(MigrateToAzureAction.LOADING_KEY, Boolean.TRUE); + log.info("[MigrationStatePreloader] Starting preload for project: {}", project.getName()); + + // Load in background thread + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + final long startTime = System.currentTimeMillis(); + final MigrateToAzureAction.MigrationState state = MigrateToAzureAction.computeState(project); + // Only cache if computation succeeded (state != null) + // If failed (e.g., MCP server error), leave cache empty so next access will retry + if (state != null) { + project.putUserData(MigrateToAzureAction.STATE_KEY, state); + log.info("[MigrationStatePreloader] Preload completed for project: {}, state: {}, took {}ms", + project.getName(), state.state, System.currentTimeMillis() - startTime); + } else { + log.warn("[MigrationStatePreloader] Preload failed for project: {}, will retry on next access", + project.getName()); + } + } catch (Exception e) { + log.error("[MigrationStatePreloader] Preload failed for project: {}", project.getName(), e); + } finally { + project.putUserData(MigrateToAzureAction.LOADING_KEY, Boolean.FALSE); + } + }); + + return Unit.INSTANCE; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/JavaUpgradeCheckStartupActivity.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/JavaUpgradeCheckStartupActivity.java new file mode 100644 index 00000000000..023d7c58dbc --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/JavaUpgradeCheckStartupActivity.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade; + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.DumbService; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesCache; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; +import com.microsoft.azure.toolkit.lib.common.task.AzureTaskManager; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +import javax.annotation.Nonnull; +import java.time.Duration; +import java.util.List; + +/** + * Startup activity that detects outdated JDK and framework versions when a project is opened. + * This runs after the project is fully loaded and shows notifications for any detected issues. + */ +@Slf4j +public class JavaUpgradeCheckStartupActivity implements ProjectActivity, DumbAware { + + // Additional delay after smart mode to ensure Maven/Gradle sync is complete + private static final long POST_INDEXING_DELAY_SECONDS = 3; + + @Override + public Object execute(@Nonnull Project project, @Nonnull Continuation continuation) { + // Wait for indexing to complete before running the check + DumbService.getInstance(project).runWhenSmart(() -> { + // Add a small delay after smart mode to ensure Maven/Gradle sync is done + Mono.delay(Duration.ofSeconds(POST_INDEXING_DELAY_SECONDS)) + .subscribe( + next -> { + if (project.isDisposed()) { + return; + } + performJavaUpgradeCheck(project); + }, + error -> { + /* Error during Java upgrade check startup */ + log.error("Error during Java upgrade check startup for project: {}", project.getName(), error); + } + ); + }); + + return null; + } + + /** + * Performs the jdk version, framework version and CVE issue check and shows notifications for any issues found. + */ + private void performJavaUpgradeCheck(@Nonnull Project project) { + try { + log.info("Starting Java upgrade issues detection for project: {}", project.getName()); + // Run the analysis in a background thread + AzureTaskManager.getInstance().runInBackground("Checking Java upgrade issues", () -> { + if (project.isDisposed()) { + return; + } + + // Refresh the cache (this populates JDK and dependency issues for use by inspections) + final JavaUpgradeIssuesCache cache = JavaUpgradeIssuesCache.getInstance(project); + cache.refresh(); + + // Get all issues including CVEs + final List allIssues = new java.util.ArrayList<>(); + allIssues.addAll(cache.getJdkIssues()); + allIssues.addAll(cache.getDependencyIssues()); + allIssues.addAll(cache.getCveIssues()); + + // Update UI on the main thread + AzureTaskManager.getInstance().runLater(() -> { + if (project.isDisposed()) { + return; + } + + // Restart code analysis to refresh inspections in open editors + // This ensures wavy underlines appear for JDK/framework issues + DaemonCodeAnalyzer.getInstance(project).restart(); + + // Show notifications if there are issues + if (!allIssues.isEmpty()) { + final JavaVersionNotificationService notificationService = JavaVersionNotificationService.getInstance(); + notificationService.showNotifications(project, allIssues); + } + }); + }); + + } catch (Throwable e) { + // Error performing Java version check + log.error("Error performing Java upgrade check for project: {}", project.getName(), e); + } + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixDependencyInProblemsViewAction.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixDependencyInProblemsViewAction.java new file mode 100644 index 00000000000..7169950c9d0 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixDependencyInProblemsViewAction.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.openapi.actionSystem.*; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.VulnerabilityInfo; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesCache; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.*; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.VulnerabilityInfo.parseVulnerabilityDescription; + +/** + * Action to fix vulnerable dependencies by opening GitHub Copilot chat with an upgrade prompt. + * This action appears in the Problems View context menu for vulnerable dependency issues. + */ +@Slf4j +public class CveFixDependencyInProblemsViewAction extends AnAction implements DumbAware { + + private static final String CVE_MARKER = "CVE-"; + + private VulnerabilityInfo vulnerabilityInfo; + public CveFixDependencyInProblemsViewAction() { + super(); + } + + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + try { + final Project project = e.getData(CommonDataKeys.PROJECT); + if (project == null || project.isDisposed()) { + return; + } + if (vulnerabilityInfo == null) { + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt( + project, + SCAN_AND_RESOLVE_CVES_PROMPT + ); + } else { + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt( + project, + String.format(FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_PROMPT, + vulnerabilityInfo.getDependencyCoordinate()) + ); + } + AppModUtils.logTelemetryEvent("openCopilotChatForCveFixDependencyInProblemsViewAction"); + } catch (Throwable ex) { + log.error("Failed to open Copilot chat for CVE fix", ex.getMessage()); + } + } + + @Override + public void update(@NotNull AnActionEvent e) { + try { + final Project project = e.getData(CommonDataKeys.PROJECT); + if (project == null || project.isDisposed()) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + + // Check if we're in the Problems View context with a vulnerability + final String description = extractProblemDescription(e); + if (description == null) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + vulnerabilityInfo = parseVulnerabilityDescription(description); + // Also check if the file is pom.xml or build.gradle (common for dependency issues) + final VirtualFile file = e.getData(CommonDataKeys.VIRTUAL_FILE); + final boolean isBuildFile = isBuildFile(file); + + if (!isBuildFile || !isCVEIssue(description)) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + final var issue = JavaUpgradeIssuesCache.getInstance(project).findCveIssue(vulnerabilityInfo.getGroupId() + ":" + vulnerabilityInfo.getArtifactId()); + if (issue == null) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + e.getPresentation().setEnabledAndVisible(true); + // e.getPresentation().setText(SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME); + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + e.getPresentation().setText(e.getPresentation().getText() + AppModPluginInstaller.TO_INSTALL_APP_MODE_PLUGIN); + } + } catch (Throwable ex) { + // In case of any error, hide the action + e.getPresentation().setEnabledAndVisible(false); + log.error("Failed to update CVE fix action visibility, hide the action", ex); + } + } + + private boolean isBuildFile(VirtualFile file){ + return file != null && + (file.getName().equals("pom.xml") || file.getName().endsWith(".gradle") || file.getName().endsWith(".gradle.kts")); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + /** + * Extracts the problem description from the action event context. + */ + @Nullable + private String extractProblemDescription(@NotNull AnActionEvent e) { + + // Approach 3: Try to get selected items from the tree + try { + final Object[] selectedItems = e.getData(PlatformDataKeys.SELECTED_ITEMS); + if (selectedItems != null && selectedItems.length > 0) { + // Concatenate all selected items' string representations + final StringBuilder sb = new StringBuilder(); + for (Object item : selectedItems) { + if (item != null) { + sb.append(item.toString()).append(" "); + } + } + final String result = sb.toString().trim(); + if (!result.isEmpty()) { + return result; + } + } + } catch (Exception ignored) { + } + return null; + } + + /** + * Extracts CVE ID from the problem description. + */ + private boolean isCVEIssue(@NotNull String description) { + // Pattern: CVE-YYYY-NNNNN + return description.toUpperCase().contains(CVE_MARKER); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixDependencyIntentionAction.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixDependencyIntentionAction.java new file mode 100644 index 00000000000..8f3114202b4 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixDependencyIntentionAction.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.codeInsight.intention.HighPriorityAction; +import com.intellij.codeInsight.intention.IntentionAction; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import com.intellij.util.IncorrectOperationException; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.VulnerabilityInfo; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesCache; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; + +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.PomXmlUtils; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.*; + +/** + * Intention action to fix CVE vulnerabilities in dependencies using GitHub Copilot. + * This action appears in the editor's quick-fix popup (More actions...) for pom.xml files(yellow wavy dependency with CVE issue) + * when a vulnerable dependency with CVE issues is detected. + * + * Implements HighPriorityAction to appear at the top of the quick-fix list. + */ +@Slf4j +public class CveFixDependencyIntentionAction implements IntentionAction, HighPriorityAction { + + // Cached dependency info from isAvailable() for use in getText() + private VulnerabilityInfo vulnerabilityInfo; + + @Override + public @IntentionName @NotNull String getText() { + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + return FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_DISPLAY_NAME + AppModPluginInstaller.TO_INSTALL_APP_MODE_PLUGIN; + } + return FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_DISPLAY_NAME; + } + + @Override + public @IntentionFamilyName @NotNull String getFamilyName() { + return "Azure Toolkit"; + } + + @Override + public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { + try { + // Reset cached values + vulnerabilityInfo = null; + + if (file == null || editor == null) { + return false; + } + + // Only available for pom.xml files + final String fileName = file.getName(); + if (!fileName.equals("pom.xml")) { + return false; + } + final int offset = editor.getCaretModel().getOffset(); + final String documentText = editor.getDocument().getText(); + + // Try to extract dependency info - only show if cursor is within a block + final int dependencyStart = PomXmlUtils.findDependencyStart(documentText, offset); + final int dependencyEnd = PomXmlUtils.findDependencyEnd(documentText, offset); + + if (dependencyStart >= 0 && dependencyEnd > dependencyStart) { + final String dependencyBlock = documentText.substring(dependencyStart, dependencyEnd); + String cachedGroupId = PomXmlUtils.extractXmlValue(dependencyBlock, "groupId"); + String cachedArtifactId = PomXmlUtils.extractXmlValue(dependencyBlock, "artifactId"); + String cachedVersion = PomXmlUtils.extractXmlValue(dependencyBlock, "version"); + vulnerabilityInfo = VulnerabilityInfo.builder().groupId(cachedGroupId).artifactId(cachedArtifactId).version(cachedVersion).build(); + // Only show if we have valid dependency info (not for parent/plugin sections) + if(cachedGroupId != null && cachedArtifactId != null) { + //if the artifact is in the cached cve issues, show the intention + final var issue = JavaUpgradeIssuesCache.getInstance(project).findCveIssue(cachedGroupId + ":" + cachedArtifactId); + return issue != null; + } + } + } catch (Throwable e) { + // Ignore and return false + log.error("Error checking availability of CveFixDependencyIntentionAction", e); + } + + return false; + } + + @Override + public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException { + try { + if (file == null || editor == null) { + return; + } + + // Try to extract dependency information from the current context + final String prompt = buildPromptFromContext(editor, file); + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt(project, prompt); + AppModUtils.logTelemetryEvent("openCveFixDependencyCopilotChatFromIntentionAction"); + } catch (Throwable e) { + log.error("Failed to invoke CveFixDependencyIntentionAction: ", e); + } + } + + /** + * Builds a prompt based on the current editor context. + */ + private String buildPromptFromContext(@NotNull Editor editor, @NotNull PsiFile file) { + if (vulnerabilityInfo == null) { + log.error("Vulnerability info is null in buildPromptFromContext"); + return SCAN_AND_RESOLVE_CVES_PROMPT; + } + return String.format(FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_PROMPT, + vulnerabilityInfo.getDependencyCoordinate()); + } + + @Override + public boolean startInWriteAction() { + return false; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixInProblemsViewAction.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixInProblemsViewAction.java new file mode 100644 index 00000000000..3bc7e0cd71e --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixInProblemsViewAction.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.actionSystem.PlatformDataKeys; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.*; + +/** + * Action to fix vulnerable dependencies by opening GitHub Copilot chat with an upgrade prompt. + * This action appears in the Problems View context menu for vulnerable dependency issues. + */ +@Slf4j +public class CveFixInProblemsViewAction extends AnAction implements DumbAware { + + private static final String CVE_MARKER = "CVE-"; + + public CveFixInProblemsViewAction() { + super(); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + try { + final Project project = e.getData(CommonDataKeys.PROJECT); + if (project == null || project.isDisposed()) { + return; + } + + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt( + project, + SCAN_AND_RESOLVE_CVES_PROMPT + ); + AppModUtils.logTelemetryEvent("openCopilotChatForCveFixInProblemsViewAction"); + } catch (Throwable ex) { + log.error("Failed to open Copilot chat for CVE fix", ex.getMessage()); + } + } + + @Override + public void update(@NotNull AnActionEvent e) { + try { + final Project project = e.getData(CommonDataKeys.PROJECT); + if (project == null || project.isDisposed()) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + + // Check if we're in the Problems View context with a vulnerability + final String description = extractProblemDescription(e); + if (description == null) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + + // Also check if the file is pom.xml or build.gradle (common for dependency issues) + final VirtualFile file = e.getData(CommonDataKeys.VIRTUAL_FILE); + final boolean isBuildFile = isBuildFile(file); + + if (!isBuildFile || !isCVEIssue(description)) { + e.getPresentation().setEnabledAndVisible(false); + return; + } + + e.getPresentation().setEnabledAndVisible(true); + e.getPresentation().setText(SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME); + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + e.getPresentation().setText(SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME + AppModPluginInstaller.TO_INSTALL_APP_MODE_PLUGIN); + } + } catch (Throwable ex) { + // In case of any error, hide the action + e.getPresentation().setEnabledAndVisible(false); + log.error("Failed to update CVE fix action visibility, hide the action", ex); + } + } + + private boolean isBuildFile(VirtualFile file){ + return file != null && + (file.getName().equals("pom.xml") || file.getName().endsWith(".gradle") || file.getName().endsWith(".gradle.kts")); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + /** + * Extracts the problem description from the action event context. + */ + @Nullable + private String extractProblemDescription(@NotNull AnActionEvent e) { + + // Approach 3: Try to get selected items from the tree + try { + final Object[] selectedItems = e.getData(PlatformDataKeys.SELECTED_ITEMS); + if (selectedItems != null && selectedItems.length > 0) { + // Concatenate all selected items' string representations + final StringBuilder sb = new StringBuilder(); + for (Object item : selectedItems) { + if (item != null) { + sb.append(item.toString()).append(" "); + } + } + final String result = sb.toString().trim(); + if (!result.isEmpty()) { + return result; + } + } + } catch (Exception ignored) { + } + return null; + } + + /** + * Extracts CVE ID from the problem description. + */ + private boolean isCVEIssue(@NotNull String description) { + // Pattern: CVE-YYYY-NNNNN + final int cveIndex = description.toUpperCase().indexOf(CVE_MARKER); + if (cveIndex >= 0) { + return true; + } + return false; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixIntentionAction.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixIntentionAction.java new file mode 100644 index 00000000000..fc55d986a8f --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/CveFixIntentionAction.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.codeInsight.intention.HighPriorityAction; +import com.intellij.codeInsight.intention.IntentionAction; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import com.intellij.util.IncorrectOperationException; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesCache; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; + +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.PomXmlUtils; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.SCAN_AND_RESOLVE_CVES_PROMPT; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME; + +/** + * Intention action to fix CVE vulnerabilities in dependencies using GitHub Copilot. + * This action appears in the editor's quick-fix popup (More actions...) for pom.xml files(yellow wavy dependency with CVE issue) + * when a vulnerable dependency with CVE issues is detected. + * + * Implements HighPriorityAction to appear at the top of the quick-fix list. + */ +@Slf4j +public class CveFixIntentionAction implements IntentionAction, HighPriorityAction { + + // Cached dependency info from isAvailable() for use in getText() + private String cachedGroupId; + private String cachedArtifactId; + + @Override + public @IntentionName @NotNull String getText() { + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + return SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME + AppModPluginInstaller.TO_INSTALL_APP_MODE_PLUGIN; + } + return SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME; + } + + @Override + public @IntentionFamilyName @NotNull String getFamilyName() { + return "Azure Toolkit"; + } + + @Override + public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { + + try { + // Reset cached values + cachedGroupId = null; + cachedArtifactId = null; + + if (file == null || editor == null) { + return false; + } + + // Only available for pom.xml files + final String fileName = file.getName(); + if (!fileName.equals("pom.xml")) { + return false; + } + final int offset = editor.getCaretModel().getOffset(); + final String documentText = editor.getDocument().getText(); + + // Try to extract dependency info - only show if cursor is within a block + final int dependencyStart = PomXmlUtils.findDependencyStart(documentText, offset); + final int dependencyEnd = PomXmlUtils.findDependencyEnd(documentText, offset); + + if (dependencyStart >= 0 && dependencyEnd > dependencyStart) { + final String dependencyBlock = documentText.substring(dependencyStart, dependencyEnd); + cachedGroupId = PomXmlUtils.extractXmlValue(dependencyBlock, "groupId"); + cachedArtifactId = PomXmlUtils.extractXmlValue(dependencyBlock, "artifactId"); + + // Only show if we have valid dependency info (not for parent/plugin sections) + if(cachedGroupId != null && cachedArtifactId != null) { + //if the artifact is in the cached cve issues, show the intention + final var issue = JavaUpgradeIssuesCache.getInstance(project).findCveIssue(cachedGroupId + ":" + cachedArtifactId); + return issue != null; + } + } + } catch (Throwable e) { + // Ignore and return false + log.error("Error in CveFixIntentionAction.isAvailable: ", e); + } + + return false; + } + + @Override + public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException { + try { + if (file == null || editor == null) { + return; + } + + // Try to extract dependency information from the current context + final String prompt = buildPromptFromContext(); + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt(project, prompt); + AppModUtils.logTelemetryEvent("openCveFixCopilotChatFromIntentionAction"); + } catch (Throwable e) { + log.error("Failed to invoke CveFixIntentionAction: ", e); + } + } + + /** + * Builds a prompt based on the current editor context. + */ + private String buildPromptFromContext() { + return SCAN_AND_RESOLVE_CVES_PROMPT; + } + + @Override + public boolean startInWriteAction() { + return false; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/JavaUpgradeContextMenuAction.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/JavaUpgradeContextMenuAction.java new file mode 100644 index 00000000000..a376688950a --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/JavaUpgradeContextMenuAction.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; + +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import static com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller.TO_INSTALL_APP_MODE_PLUGIN; +import static com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller.isAppModPluginInstalled; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.UPGRADE_JAVA_AND_FRAMEWORK_PROMPT; + +/** + * Context menu action to upgrade a Java project using GitHub Copilot. + * This action appears in the GitHub Copilot submenu when right-clicking on: + * - Project root folder + * - pom.xml (Maven projects) + * - build.gradle or build.gradle.kts (Gradle projects) + */ +@Slf4j +public class JavaUpgradeContextMenuAction extends AnAction { + // text, description, and icon are defined in azure-intellij-plugin-appmod.xml + public JavaUpgradeContextMenuAction() { + super(); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + @Override + public void update(@NotNull AnActionEvent e) { + try { + final Project project = e.getProject(); + final VirtualFile file = e.getData(CommonDataKeys.VIRTUAL_FILE); + + boolean visible = false; + + if (project != null && file != null) { + // Check if it's a project root, pom.xml, or build.gradle file + visible = isProjectRoot(project, file) || + isMavenBuildFile(file) || + isGradleBuildFile(file); + } + if (!isAppModPluginInstalled()) { + e.getPresentation().setText(e.getPresentation().getText() + TO_INSTALL_APP_MODE_PLUGIN); + } + if (visible){ + AppModUtils.logTelemetryEvent("showJavaUpgradeContextMenuAction"); + } + e.getPresentation().setEnabledAndVisible(visible); + } catch (Throwable ex) { + // In case of any error, hide the action + e.getPresentation().setEnabledAndVisible(false); + } + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + try { + final Project project = e.getProject(); + if (project == null) { + return; + } + + final VirtualFile file = e.getData(CommonDataKeys.VIRTUAL_FILE); + String prompt = buildUpgradePrompt(); + + // Open Copilot chat with the upgrade prompt + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt(project, prompt); + AppModUtils.logTelemetryEvent("openJavaUpgradeCopilotChatFromContextMenu"); + } catch (Throwable ex) { + // Log error but do not crash + log.error("Failed to perform Java upgrade action from context menu", ex); + } + } + + /** + * Builds the upgrade prompt based on the selected file. + */ + private String buildUpgradePrompt() { + return UPGRADE_JAVA_AND_FRAMEWORK_PROMPT; + } + + /** + * Checks if the file is the project root directory. + */ + private boolean isProjectRoot(Project project, VirtualFile file) { + if (!file.isDirectory()) { + return false; + } + + final VirtualFile projectBaseDir = project.getBaseDir(); + if (projectBaseDir == null) { + return false; + } + + return file.equals(projectBaseDir); + } + + /** + * Checks if the file is a Maven build file (pom.xml). + */ + private boolean isMavenBuildFile(VirtualFile file) { + return file != null && !file.isDirectory() && "pom.xml".equals(file.getName()); + } + + /** + * Checks if the file is a Gradle build file (build.gradle or build.gradle.kts). + */ + private boolean isGradleBuildFile(VirtualFile file) { + if (file == null || file.isDirectory()) { + return false; + } + final String name = file.getName(); + return "build.gradle".equals(name) || "build.gradle.kts".equals(name); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/JavaUpgradeQuickFix.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/JavaUpgradeQuickFix.java new file mode 100644 index 00000000000..aa6076f24bb --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/JavaUpgradeQuickFix.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.UPGRADE_JAVA_FRAMEWORK_PROMPT; + +/** + * Quick fix for Java/Spring version upgrade issues detected in pom.xml files. + * This action is triggered from Java upgrade issue inspections and opens Copilot + * to assist with the upgrade process. + */ +@Slf4j +public class JavaUpgradeQuickFix implements LocalQuickFix { + private final JavaUpgradeIssue issue; + + public JavaUpgradeQuickFix(@NotNull JavaUpgradeIssue issue) { + this.issue = issue; + } + + @Nls(capitalization = Nls.Capitalization.Sentence) + @NotNull + @Override + public String getFamilyName() { + return "Azure Toolkit"; + } + + @Nls(capitalization = Nls.Capitalization.Sentence) + @NotNull + @Override + public String getName() { + String name = "Upgrade " + issue.getPackageDisplayName() + " with Copilot"; + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + return name + AppModPluginInstaller.TO_INSTALL_APP_MODE_PLUGIN; + } + return name; + } + + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + try { + String prompt = buildPromptForIssue(issue); + JavaVersionNotificationService.getInstance().openCopilotChatWithPrompt(project, prompt); + AppModUtils.logTelemetryEvent("openCopilotChatForJavaUpgradeQuickFix"); + } catch (Throwable ex) { + log.error("Failed to apply Java upgrade quick fix", ex); + } + } + + private String buildPromptForIssue(@NotNull JavaUpgradeIssue issue) { + return String.format( + UPGRADE_JAVA_FRAMEWORK_PROMPT, + issue.getPackageDisplayName(), issue.getCurrentVersion(), issue.getSuggestedVersion() + ); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/UpgradeActionRegistrar.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/UpgradeActionRegistrar.java new file mode 100644 index 00000000000..d0d58a23ce7 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/action/UpgradeActionRegistrar.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action; + +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.actionSystem.Presentation; +import com.intellij.openapi.actionSystem.Separator; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; + +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Registers the Upgrade action into the GitHub Copilot context menu at runtime. + * This is needed because the Copilot plugin creates its context menu groups dynamically. + */ +@Slf4j +public class UpgradeActionRegistrar implements ProjectActivity { + + private static final String UPGRADE_ACTION_ID = "AzureToolkit.JavaUpgradeContextMenu"; + private static final String PROJECT_VIEW_POPUP_MENU = "ProjectViewPopupMenu"; + + @Nullable + @Override + public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + try{ + discoverAndRegisterAction(); + } catch (Throwable e) { + log.error("Failed to register Upgrade action in Copilot context menu.", e); + } + return Unit.INSTANCE; + } + + private void discoverAndRegisterAction() { + // Only proceed if Copilot plugin is installed + if (!AppModPluginInstaller.isCopilotInstalled()) { + log.info("GitHub Copilot plugin not installed; skipping UpgradeActionRegistrar."); + return; + } + + ActionManager actionManager = ActionManager.getInstance(); + + // Get the ProjectViewPopupMenu group + AnAction projectViewPopup = actionManager.getAction(PROJECT_VIEW_POPUP_MENU); + if (projectViewPopup instanceof DefaultActionGroup) { + DefaultActionGroup popupGroup = (DefaultActionGroup) projectViewPopup; + + // Search for the GitHub Copilot submenu within ProjectViewPopupMenu + DefaultActionGroup copilotGroup = findCopilotSubmenu(popupGroup, actionManager); + + if (copilotGroup != null) { + tryAddToGroup(actionManager, copilotGroup, "GitHub Copilot submenu"); + } + } + } + + /** + * Search for the GitHub Copilot submenu within a parent group. + * The Copilot plugin creates this dynamically, so we search by exact presentation text. + */ + private DefaultActionGroup findCopilotSubmenu(DefaultActionGroup parentGroup, ActionManager actionManager) { + for (AnAction child : parentGroup.getChildActionsOrStubs()) { + if (child instanceof DefaultActionGroup) { + DefaultActionGroup childGroup = (DefaultActionGroup) child; + Presentation presentation = childGroup.getTemplatePresentation(); + String text = presentation.getText(); + String actionId = actionManager.getId(child); + + // Match exactly "GitHub Copilot" to avoid false positives + if ("GitHub Copilot".equals(text)) { + return childGroup; + } + } + } + return null; + } + + private void tryAddToGroup(ActionManager actionManager, DefaultActionGroup group, String groupId) { + AnAction upgradeAction = actionManager.getAction(UPGRADE_ACTION_ID); + if (upgradeAction == null) { + return; + } + + // Check if action is not already added + if (!containsAction(group, UPGRADE_ACTION_ID, actionManager)) { + // Add a separator before the upgrade action to visually group it + group.add(Separator.create()); + AppModUtils.logTelemetryEvent("java-upgrade.contextmenu.action.registered"); + group.add(upgradeAction); + log.info("Registered Upgrade action into {}.", groupId); + } + } + + private boolean containsAction(DefaultActionGroup group, String actionId, ActionManager actionManager) { + for (AnAction action : group.getChildActionsOrStubs()) { + if (action != null && actionId.equals(actionManager.getId(action))) { + return true; + } + } + return false; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/dao/JavaUpgradeIssue.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/dao/JavaUpgradeIssue.java new file mode 100644 index 00000000000..b4ddceedd89 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/dao/JavaUpgradeIssue.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao; + +import lombok.Builder; +import lombok.Data; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Represents a detected upgrade issue in a Java project. + * This class is aligned with the TypeScript implementation in vscode-java-dependency. + * + * @see type.ts + */ +@Data +@Builder +public class JavaUpgradeIssue { + + /** + * The reason why an upgrade is recommended. + * Aligned with UpgradeReason enum from vscode-java-dependency. + */ + public enum UpgradeReason { + /** + * The version has reached end of life and is no longer maintained. + */ + END_OF_LIFE, + /** + * The version is deprecated and should be upgraded. + */ + DEPRECATED, + /** + * The version has known security vulnerabilities (CVEs). + */ + CVE, + /** + * The JRE/JDK version is too old compared to the mature LTS version. + */ + JRE_TOO_OLD + } + + /** + * Severity level of the issue. + */ + public enum Severity { + /** + * The version is very old and may have security vulnerabilities or be unsupported. + */ + CRITICAL, + /** + * The version is outdated and should be upgraded. + */ + WARNING, + /** + * The version is slightly behind the latest, informational only. + */ + INFO + } + + /** + * The package identifier (groupId:artifactId or "jdk" for JDK issues). + */ + @Nonnull + private String packageId; + + /** + * The display name of the package (e.g., "Spring Boot", "JDK"). + */ + @Nonnull + private String packageDisplayName; + + /** + * The reason why upgrade is recommended. + */ + @Nonnull + private UpgradeReason upgradeReason; + + /** + * Severity level of this issue. + */ + @Nonnull + private Severity severity; + + /** + * The current version detected in the project. + */ + @Nullable + private String currentVersion; + + /** + * The supported version range (e.g., "2.7.x || >=3.2"). + */ + @Nullable + private String supportedVersion; + + /** + * The suggested version to upgrade to. + */ + @Nullable + private String suggestedVersion; + + /** + * Detailed message describing the issue. + */ + @Nonnull + private String message; + + /** + * URL for more information about the issue. + */ + @Nullable + private String learnMoreUrl; + + /** + * CVE identifier if this is a CVE issue. + */ + @Nullable + private String cveId; + + @Nullable + private String eofDate; + + /** + * Gets a formatted title for the notification. + */ + public String getTitle() { + return switch (upgradeReason) { + case JRE_TOO_OLD -> "Outdated JDK Detected"; + case END_OF_LIFE -> packageDisplayName + " End of Life"; + case DEPRECATED -> packageDisplayName + " Deprecated"; + case CVE -> "Security Vulnerability in " + packageDisplayName; + }; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/dao/VulnerabilityInfo.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/dao/VulnerabilityInfo.java new file mode 100644 index 00000000000..ceb44ab7f47 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/dao/VulnerabilityInfo.java @@ -0,0 +1,88 @@ +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao; + + +import lombok.Builder; +import lombok.Data; +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represents a parsed vulnerability with dependency information and CVE IDs. + */ +@Data +@Builder +public class VulnerabilityInfo { + // Pattern to match Maven dependency: maven:groupId:artifactId:version + private static final Pattern MAVEN_DEPENDENCY_PATTERN = + Pattern.compile("maven:([^:]+):([^:]+):([^\\s]+)"); + + // Pattern to match CVE IDs: CVE-YYYY-NNNNN (year and number) + private static final Pattern CVE_PATTERN = + Pattern.compile("CVE-\\d{4}-\\d{4,}"); + @NotNull + private String groupId; + @NotNull + private String artifactId; + @Nullable + private String version; + @Nullable + private List cveIds; + + /** + * Returns the full Maven coordinate in format groupId:artifactId:version + */ + public String getDependencyCoordinate() { + if (version == null){ + return groupId + ":" + artifactId; + } + return groupId + ":" + artifactId + ":" + version; + } + + @Override + public String toString() { + return "VulnerabilityInfo{" + + "dependency='" + getDependencyCoordinate() + '\'' + + ", cveIds=" + cveIds + + '}'; + } + + + /** + * Parses a vulnerability description to extract the dependency coordinate and CVE IDs. + *

+ * Expected format: "Provides transitive vulnerable dependency maven:groupId:artifactId:version CVE-YYYY-NNNNN score ..." + * + * @param description The vulnerability description from the Problems View + * @return VulnerabilityInfo containing the parsed dependency and CVE IDs, or null if parsing fails + */ + @Nullable + public static VulnerabilityInfo parseVulnerabilityDescription(@Nullable String description) { + if (description == null || description.isEmpty()) { + return null; + } + + // Extract Maven dependency coordinate + final Matcher dependencyMatcher = MAVEN_DEPENDENCY_PATTERN.matcher(description); + if (!dependencyMatcher.find()) { + return null; + } + + final String groupId = dependencyMatcher.group(1); + final String artifactId = dependencyMatcher.group(2); + final String version = dependencyMatcher.group(3); + + // Extract all CVE IDs + final List cveIds = new ArrayList<>(); + final Matcher cveMatcher = CVE_PATTERN.matcher(description.toUpperCase()); + while (cveMatcher.find()) { + cveIds.add(cveMatcher.group()); + } + + return new VulnerabilityInfo(groupId, artifactId, version, cveIds); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/inspection/JavaUpgradeIssuesInspection.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/inspection/JavaUpgradeIssuesInspection.java new file mode 100644 index 00000000000..d998ed69044 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/inspection/JavaUpgradeIssuesInspection.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.inspection; + +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.codeInspection.ProblemsHolder; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElementVisitor; +import com.intellij.psi.PsiFile; +import com.intellij.psi.XmlElementVisitor; +import com.intellij.psi.xml.XmlFile; +import com.intellij.psi.xml.XmlTag; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action.JavaUpgradeQuickFix; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesCache; + +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesDetectionService.*; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Inspection that displays Java upgrade issues detected by JavaUpgradeDetectionService. + * Shows JDK version and framework version issues in pom.xml files with wavy underlines. + * + * Note: Issues are cached at project startup via JavaUpgradeIssueCache to avoid + * repeated expensive scans during inspection runs. + */ +@Slf4j +public class JavaUpgradeIssuesInspection extends LocalInspectionTool { + + @NotNull + @Override + public PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { + final PsiFile file = holder.getFile(); + + // Only process pom.xml files + if (!(file instanceof XmlFile) || !file.getName().equals("pom.xml")) { + return PsiElementVisitor.EMPTY_VISITOR; + } + + final Project project = holder.getProject(); + final JavaUpgradeIssuesCache cache = JavaUpgradeIssuesCache.getInstance(project); + + // Skip if cache is not yet initialized (will show issues after startup completes) + if (!cache.isInitialized()) { + return PsiElementVisitor.EMPTY_VISITOR; + } + + // Get cached issues (computed once at project startup) + final JavaUpgradeIssue jdkIssue = cache.getJdkIssue(); + final List dependencyIssues = cache.getDependencyIssues(); + + return new XmlElementVisitor() { + @Override + public void visitXmlTag(@NotNull XmlTag tag) { + super.visitXmlTag(tag); + + // Check for JDK version tags + if (jdkIssue != null) { + if (isJavaVersionProperty(tag) || isCompilerPluginVersionTag(tag)) { + registerProblem(holder, tag, jdkIssue); + } + } + + // Check for dependency/parent version tags and register all matching issues + if ("version".equals(tag.getName())) { + XmlTag parentElement = tag.getParentTag(); + if (parentElement != null) { + String parentTagName = parentElement.getName(); + if ("dependency".equals(parentTagName) || "parent".equals(parentTagName)) { + XmlTag groupIdTag = parentElement.findFirstSubTag("groupId"); + XmlTag artifactIdTag = parentElement.findFirstSubTag("artifactId"); + if (groupIdTag != null && artifactIdTag != null) { + String packageId = groupIdTag.getValue().getText() + ":" + artifactIdTag.getValue().getText(); + // Register all issues that match this package + for (JavaUpgradeIssue issue : dependencyIssues) { + if (matchesPackageId(packageId, issue.getPackageId())) { + registerProblem(holder, tag, issue); + } + } + } + } + } + } + } + }; + } + + private void registerProblem(@NotNull ProblemsHolder holder, @NotNull XmlTag tag, @NotNull JavaUpgradeIssue issue) { + log.info("Registering Java upgrade issue in inspection: {}", issue); + holder.registerProblem( + tag, + issue.getMessage(), + ProblemHighlightType.WEAK_WARNING, + new JavaUpgradeQuickFix(issue) + ); + } + + /** + * Checks if the packageId matches the issue's packageId pattern. + * Supports wildcard patterns like "org.springframework.boot:*" to match any artifact in a group. + */ + private boolean matchesPackageId(@NotNull String packageId, @NotNull String issuePackageId) { + if (issuePackageId.endsWith(":*")) { + // Wildcard match: check if packageId starts with the group prefix + String groupPrefix = issuePackageId.substring(0, issuePackageId.length() - 1); // "org.springframework.boot:" + return packageId.startsWith(groupPrefix); + } + return packageId.equals(issuePackageId); + } + + /** + * Checks if the tag is a Java version property (java.version, maven.compiler.source, maven.compiler.target). + */ + private boolean isJavaVersionProperty(@NotNull XmlTag tag) { + String tagName = tag.getName(); + XmlTag parent = tag.getParentTag(); + + if (parent == null || !"properties".equals(parent.getName())) { + return false; + } + + return "java.version".equals(tagName) || + "maven.compiler.source".equals(tagName) || + "maven.compiler.target".equals(tagName) || + "maven.compiler.release".equals(tagName); + } + + /** + * Checks if the tag is a version tag inside maven-compiler-plugin configuration. + */ + private boolean isCompilerPluginVersionTag(@NotNull XmlTag tag) { + String tagName = tag.getName(); + if (!"source".equals(tagName) && !"target".equals(tagName) && !"release".equals(tagName)) { + return false; + } + + // Check if we're inside maven-compiler-plugin + XmlTag current = tag.getParentTag(); + while (current != null) { + if ("plugin".equals(current.getName())) { + XmlTag artifactIdTag = current.findFirstSubTag("artifactId"); + if (artifactIdTag != null && ARTIFACT_ID_MAVEN_COMPILER_PLUGIN.equals(artifactIdTag.getValue().getText())) { + return true; + } + } + current = current.getParentTag(); + } + return false; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/CVECheckService.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/CVECheckService.java new file mode 100644 index 00000000000..baed0899a2a --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/CVECheckService.java @@ -0,0 +1,580 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service; + +import com.intellij.util.net.JdkProxyProvider; +import com.intellij.util.net.ssl.CertificateManager; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.Builder; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.maven.artifact.versioning.ComparableVersion; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Service to check for CVE (Common Vulnerabilities and Exposures) issues in Maven dependencies. + * Uses GitHub's Security Advisory API to fetch vulnerability information. + * + * This implementation is aligned with the TypeScript version in vscode-java-dependency. + * @see cve.ts + */ +@Slf4j +public class CVECheckService { + + private static final String GITHUB_API_BASE = "https://api.github.com/advisories"; + private static final int BATCH_SIZE = 30; + private static final int REQUEST_TIMEOUT_SECONDS = 30; + + /** + * Severity levels ordered by criticality (higher number = more critical). + * Aligned with GitHub Security Advisory severity levels. + */ + public enum Severity { + UNKNOWN(0), + LOW(1), + MEDIUM(2), + HIGH(3), + CRITICAL(4); + + private final int level; + + Severity(int level) { + this.level = level; + } + + public int getLevel() { + return level; + } + + public static Severity fromString(String severity) { + if (severity == null) return UNKNOWN; + return switch (severity.toLowerCase()) { + case "critical" -> CRITICAL; + case "high" -> HIGH; + case "medium" -> MEDIUM; + case "low" -> LOW; + default -> UNKNOWN; + }; + } + } + + /** + * Represents a CVE (Common Vulnerabilities and Exposures) entry. + */ + @Data + @Builder + public static class CVE { + private String id; + private String ghsaId; + private Severity severity; + private String summary; + private String description; + private String htmlUrl; + private List affectedDeps; + } + + /** + * Represents a dependency affected by a CVE. + */ + @Data + @Builder + public static class AffectedDependency { + private String name; + private String vulnerableVersionRange; + private String patchedVersion; + } + + /** + * Represents a dependency coordinate (groupId:artifactId:version). + */ + @Data + @Builder + public static class DependencyCoordinate { + private String groupId; + private String artifactId; + private String version; + + public String getName() { + return groupId + ":" + artifactId; + } + + public String getCoordinate() { + return groupId + ":" + artifactId + ":" + version; + } + } + + private static CVECheckService instance; + private final HttpClient httpClient; + private final Gson gson; + + private CVECheckService() { + final HttpClient.Builder builder = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(REQUEST_TIMEOUT_SECONDS)); + try { + // Configure proxy using IntelliJ's JdkProxyProvider + // This respects the IDE's proxy settings (Settings → HTTP Proxy) + builder.proxy(JdkProxyProvider.getInstance().getProxySelector()); + } catch (Throwable e) { + log.error("Failed to configure proxy from IntelliJ proxy settings", e); + } + try{ + builder.sslContext(CertificateManager.getInstance().getSslContext()); + } catch (Throwable e) { + // Failed to get IntelliJ SSL context, using default + log.error("Failed to configure HTTP client SSL context from IntelliJ CertificateManager, using default.", e); + } + this.httpClient = builder.build(); + this.gson = new Gson(); + } + + public static synchronized CVECheckService getInstance() { + if (instance == null) { + instance = new CVECheckService(); + } + return instance; + } + + /** + * Batch check CVE issues for a list of Maven coordinates. + * Aligned with batchGetCVEIssues() from cve.ts. + * + * @param coordinates List of Maven coordinates in format "groupId:artifactId:version" + * @return List of CVE-related upgrade issues + */ + @Nonnull + public List batchGetCVEIssues(@Nonnull List coordinates) { + final List allIssues = new ArrayList<>(); + + // Process dependencies in batches to avoid URL length limits + for (int i = 0; i < coordinates.size(); i += BATCH_SIZE) { + final List batch = coordinates.subList(i, Math.min(i + BATCH_SIZE, coordinates.size())); + try { + final List batchIssues = getCveUpgradeIssues(batch); + allIssues.addAll(batchIssues); + } catch (Exception e) { + log.error("Error fetching CVE issues for batch starting at index " + i, e); + } + } + + return allIssues; + } + + /** + * Get CVE upgrade issues for a batch of coordinates. + */ + @Nonnull + private List getCveUpgradeIssues(@Nonnull List coordinates) { + if (coordinates.isEmpty()) { + return Collections.emptyList(); + } + + final List deps = coordinates.stream() + .map(this::parseCoordinate) + .filter(Objects::nonNull) + .filter(d -> StringUtils.isNotBlank(d.getVersion())) + .collect(Collectors.toList()); + + if (deps.isEmpty()) { + return Collections.emptyList(); + } + + final List depsCves = fetchCves(deps); + return mapCvesToUpgradeIssues(depsCves); + } + + /** + * Parse a Maven coordinate string into a DependencyCoordinate object. + */ + @Nullable + private DependencyCoordinate parseCoordinate(@Nonnull String coordinate) { + final String[] parts = coordinate.split(":", 3); + if (parts.length < 3) { + return null; + } + return DependencyCoordinate.builder() + .groupId(parts[0]) + .artifactId(parts[1]) + .version(parts[2]) + .build(); + } + + /** + * Represents a dependency with its associated CVEs. + */ + @Data + @Builder + private static class DepsCves { + private String dep; + private String version; + private List cves; + } + + /** + * Fetch CVEs from GitHub Security Advisory API. + */ + @Nonnull + private List fetchCves(@Nonnull List deps) { + if (deps.isEmpty()) { + return Collections.emptyList(); + } + + try { + final List allCves = retrieveVulnerabilityData(deps); + + if (allCves.isEmpty()) { + return Collections.emptyList(); + } + + // Group the CVEs by coordinate + final List depsCves = new ArrayList<>(); + + for (DependencyCoordinate dep : deps) { + final List depCves = allCves.stream() + .filter(cve -> isCveAffectingDep(cve, dep.getName(), dep.getVersion())) + .collect(Collectors.toList()); + + if (!depCves.isEmpty()) { + depsCves.add(DepsCves.builder() + .dep(dep.getName()) + .version(dep.getVersion()) + .cves(depCves) + .build()); + } + } + + return depsCves; + } catch (Exception e) { + // Error fetching CVEs + log.error("Error fetching CVEs from GitHub Security Advisory API", e); + return Collections.emptyList(); + } + } + + + + /** + * Retrieve vulnerability data from GitHub Security Advisory API. + * Only fetches critical and high severity CVEs for Maven ecosystem. + */ + @Nonnull + private List retrieveVulnerabilityData(@Nonnull List deps) { + if (deps.isEmpty()) { + return Collections.emptyList(); + } + + try { + // Build the affects parameter: package@version format + // Based on TS: deps.map((p) => `${p.name}@${p.version}`) passed as array to octokit. + // Octokit usually serializes array as repeated params affects=a&affects=b OR comma-sep. + // GitHub API docs say "iterable". Standard approach for "iterable" in query string is repeated params. + // But previous Java code used comma. I will stick to comma as it likely works, or change if needed. + // TS Octokit behavior for "affects" param -> comma separated string is often accepted. + final String affects = deps.stream() + .map(d -> URLEncoder.encode(d.getName() + "@" + d.getVersion(), StandardCharsets.UTF_8)) + .collect(Collectors.joining(",")); + + final List allCves = new ArrayList<>(); + int page = 1; + + while (true) { + final String url = String.format( + "%s?ecosystem=maven&affects=%s&per_page=100&sort=published&direction=asc&page=%d", + GITHUB_API_BASE, affects, page + ); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .timeout(Duration.ofSeconds(REQUEST_TIMEOUT_SECONDS)) + .GET() + .build(); + + final HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + // GitHub API returned non-200 status + break; + } + + final JsonArray advisories = gson.fromJson(response.body(), JsonArray.class); + if (advisories.isEmpty()) { + break; + } + + // Parse and add to list + allCves.addAll(parseAdvisories(advisories)); + + if (advisories.size() < 100) { + break; + } + + page++; + } + + return allCves; + + } catch (Exception e) { + // Error retrieving vulnerability data from GitHub + log.error("Error retrieving vulnerability data from GitHub Security Advisory API", e); + return Collections.emptyList(); + } + } + + /** + * Parse GitHub Security Advisory API response into CVE objects. + */ + @Nonnull + private List parseAdvisories(@Nonnull JsonArray advisories) { + final List cves = new ArrayList<>(); + + try { + for (JsonElement element : advisories) { + final JsonObject advisory = element.getAsJsonObject(); + + // Skip withdrawn advisories + if (advisory.has("withdrawn_at") && !advisory.get("withdrawn_at").isJsonNull()) { + continue; + } + + final String severity = getStringOrNull(advisory, "severity"); + final Severity severityEnum = Severity.fromString(severity); + + // Only consider critical and high severity CVEs + if (severityEnum != Severity.CRITICAL && severityEnum != Severity.HIGH) { + continue; + } + + final String cveId = getStringOrNull(advisory, "cve_id"); + final String ghsaId = getStringOrNull(advisory, "ghsa_id"); + final String id = StringUtils.isNotBlank(cveId) ? cveId : ghsaId; + + final List affectedDeps = new ArrayList<>(); + if (advisory.has("vulnerabilities") && !advisory.get("vulnerabilities").isJsonNull()) { + final JsonArray vulnerabilities = advisory.getAsJsonArray("vulnerabilities"); + for (JsonElement vulnElement : vulnerabilities) { + final JsonObject vuln = vulnElement.getAsJsonObject(); + String packageName = null; + if (vuln.has("package") && !vuln.get("package").isJsonNull()) { + packageName = getStringOrNull(vuln.getAsJsonObject("package"), "name"); + } + + affectedDeps.add(AffectedDependency.builder() + .name(packageName) + .vulnerableVersionRange(getStringOrNull(vuln, "vulnerable_version_range")) + .patchedVersion(getStringOrNull(vuln, "first_patched_version")) + .build()); + } + } + + cves.add(CVE.builder() + .id(id) + .ghsaId(ghsaId) + .severity(severityEnum) + .summary(getStringOrNull(advisory, "summary")) + .description(getStringOrNull(advisory, "description")) + .htmlUrl(getStringOrNull(advisory, "html_url")) + .affectedDeps(affectedDeps) + .build()); + } + + } catch (Exception e) { + // Error parsing advisory JSON + log.error("Error parsing advisories JsonArray from GitHub Security Advisory API", e); + } + + return cves; + } + + /** + * Parse GitHub Security Advisory API response into CVE objects. + * Legacy method for backward compatibility if needed, or helper + */ + @Nonnull + private List parseAdvisories(@Nonnull String jsonResponse) { + try { + return parseAdvisories(gson.fromJson(jsonResponse, JsonArray.class)); + } catch (Exception e) { + // Error parsing advisory JSON + log.error("Error parsing advisories JsonString from GitHub Security Advisory API", e); + return Collections.emptyList(); + } + } + + @Nullable + private String getStringOrNull(@Nonnull JsonObject obj, @Nonnull String key) { + if (obj.has(key) && !obj.get(key).isJsonNull()) { + return obj.get(key).getAsString(); + } + return null; + } + + /** + * Map CVEs to upgrade issues. + */ + @Nonnull + private List mapCvesToUpgradeIssues(@Nonnull List depsCves) { + if (depsCves.isEmpty()) { + return Collections.emptyList(); + } + + return depsCves.stream() + .map(this::mapDepCvesToUpgradeIssue) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * Map a single dependency's CVEs to an upgrade issue. + * Uses the most critical CVE for the issue details. + */ + @Nullable + private JavaUpgradeIssue mapDepCvesToUpgradeIssue(@Nonnull DepsCves depCve) { + if (depCve.getCves() == null || depCve.getCves().isEmpty()) { + return null; + } + + // Find the most critical CVE + final CVE mostCriticalCve = depCve.getCves().stream() + .max(Comparator.comparingInt(cve -> cve.getSeverity().getLevel())) + .orElse(depCve.getCves().get(0)); + + // Build the message + final String message = String.format( + "Security vulnerability %s detected in %s %s. %s", + mostCriticalCve.getId() != null ? mostCriticalCve.getId() : "CVE", + depCve.getDep(), + depCve.getVersion(), + StringUtils.isNotBlank(mostCriticalCve.getSummary()) ? + mostCriticalCve.getSummary() : + "Please upgrade to a patched version." + ); + + // Determine suggested version from patched versions + String suggestedVersion = null; + if (mostCriticalCve.getAffectedDeps() != null) { + suggestedVersion = mostCriticalCve.getAffectedDeps().stream() + .filter(ad -> depCve.getDep().equals(ad.getName())) + .map(AffectedDependency::getPatchedVersion) + .filter(StringUtils::isNotBlank) + .findFirst() + .orElse(null); + } + + return JavaUpgradeIssue.builder() + .packageId(depCve.getDep()) + .packageDisplayName(depCve.getDep()) + .upgradeReason(JavaUpgradeIssue.UpgradeReason.CVE) + .severity(mapCveSeverityToIssueSeverity(mostCriticalCve.getSeverity())) + .currentVersion(depCve.getVersion()) + .suggestedVersion(suggestedVersion) + .message(message) + .learnMoreUrl(mostCriticalCve.getHtmlUrl()) + .cveId(mostCriticalCve.getId()) + .build(); + } + + /** + * Map CVE severity to issue severity. + */ + @Nonnull + private JavaUpgradeIssue.Severity mapCveSeverityToIssueSeverity(@Nonnull Severity cveSeverity) { + return switch (cveSeverity) { + case CRITICAL -> JavaUpgradeIssue.Severity.CRITICAL; + case HIGH -> JavaUpgradeIssue.Severity.WARNING; + default -> JavaUpgradeIssue.Severity.INFO; + }; + } + + /** + * Check if a CVE affects a specific dependency at a specific version. + * Aligned with isCveAffectingDep() from cve.ts. + */ + private boolean isCveAffectingDep(@Nonnull CVE cve, @Nonnull String depName, @Nonnull String depVersion) { + if (cve.getAffectedDeps() == null || cve.getAffectedDeps().isEmpty()) { + return false; + } + + return cve.getAffectedDeps().stream() + .anyMatch(ad -> depName.equals(ad.getName()) && + isVersionInRange(depVersion, ad.getVulnerableVersionRange())); + } + + /** + * Check if a version satisfies a vulnerability version range. + * Handles common range formats like ">= 1.0, < 2.0", "< 3.0", etc. + */ + private boolean isVersionInRange(@Nonnull String version, @Nullable String range) { + if (StringUtils.isBlank(range)) { + return false; + } + + try { + final ComparableVersion currentVersion = new ComparableVersion(version); + + // Split by comma for compound ranges (e.g., ">= 1.0, < 2.0") + final String[] conditions = range.split(","); + + for (String condition : conditions) { + condition = condition.trim(); + + if (!satisfiesCondition(currentVersion, condition)) { + return false; + } + } + + return true; + } catch (Exception e) { + // Error checking version range + log.error("Error checking if version {} is in range {}", version, range, e); + return false; + } + } + + /** + * Check if a version satisfies a single condition. + */ + private boolean satisfiesCondition(@Nonnull ComparableVersion version, @Nonnull String condition) { + condition = condition.trim(); + + if (condition.startsWith(">=")) { + final ComparableVersion min = new ComparableVersion(condition.substring(2).trim()); + return version.compareTo(min) >= 0; + } else if (condition.startsWith(">")) { + final ComparableVersion min = new ComparableVersion(condition.substring(1).trim()); + return version.compareTo(min) > 0; + } else if (condition.startsWith("<=")) { + final ComparableVersion max = new ComparableVersion(condition.substring(2).trim()); + return version.compareTo(max) <= 0; + } else if (condition.startsWith("<")) { + final ComparableVersion max = new ComparableVersion(condition.substring(1).trim()); + return version.compareTo(max) < 0; + } else if (condition.startsWith("=")) { + final ComparableVersion exact = new ComparableVersion(condition.substring(1).trim()); + return version.compareTo(exact) == 0; + } else { + // Exact version match + final ComparableVersion exact = new ComparableVersion(condition); + return version.compareTo(exact) == 0; + } + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaUpgradeIssuesCache.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaUpgradeIssuesCache.java new file mode 100644 index 00000000000..7cd57e86067 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaUpgradeIssuesCache.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Project-level cache for Java upgrade issues. + * Issues are computed once at startup and cached to avoid repeated expensive scans + * during inspection runs. The cache can be invalidated when project model changes. + */ +@Slf4j +@Service(Service.Level.PROJECT) +public final class JavaUpgradeIssuesCache implements Disposable { + + private final Project project; + private List jdkIssuesCache = new ArrayList<>(); + private List dependencyIssuesCache = new ArrayList<>(); + private List cvesIssuesCache = new ArrayList<>(); + private final AtomicBoolean initialized = new AtomicBoolean(false); + + public JavaUpgradeIssuesCache(@NotNull Project project) { + this.project = project; + } + + public static JavaUpgradeIssuesCache getInstance(@NotNull Project project) { + return project.getService(JavaUpgradeIssuesCache.class); + } + + /** + * Gets cached JDK issues. Returns empty list if not yet initialized. + */ + @Nonnull + public List getJdkIssues() { + return jdkIssuesCache != null ? jdkIssuesCache : Collections.emptyList(); + } + + /** + * Gets cached dependency issues. Returns empty list if not yet initialized. + */ + @Nonnull + public List getDependencyIssues() { + return dependencyIssuesCache != null ? dependencyIssuesCache : Collections.emptyList(); + } + + /** + * Gets cached CVE issues. Returns empty list if not yet initialized. + */ + @Nonnull + public List getCveIssues() { + return cvesIssuesCache != null ? cvesIssuesCache : Collections.emptyList(); + } + + /** + * Finds a specific CVE issue by package ID prefix. + */ + @Nullable + public JavaUpgradeIssue findCveIssue(@Nonnull String packageIdPrefix) { + return getCveIssues().stream() + .filter(i -> i.getPackageId().startsWith(packageIdPrefix)) + .findFirst() + .orElse(null); + } + /** + * Finds the first issue matching a package ID prefix. + * @deprecated Use {@link #findDependencyIssues(String)} to handle multiple dependencies with the same groupId. + */ + @Deprecated + @Nullable + public JavaUpgradeIssue findDependencyIssue(@Nonnull String packageIdPrefix) { + return getDependencyIssues().stream() + .filter(i -> i.getPackageId().startsWith(packageIdPrefix)) + .findFirst() + .orElse(null); + } + + /** + * Finds all issues matching a package ID prefix (groupId). + * This handles the case where multiple dependencies share the same groupId. + */ + @Nonnull + public List findDependencyIssues(@Nonnull String packageIdPrefix) { + return getDependencyIssues().stream() + .filter(i -> i.getPackageId().startsWith(packageIdPrefix)) + .toList(); + } + + /** + * Gets the first JDK issue if present. + */ + @Nullable + public JavaUpgradeIssue getJdkIssue() { + List issues = getJdkIssues(); + return issues.isEmpty() ? null : issues.get(0); + } + + /** + * Checks if the cache has been initialized. + */ + public boolean isInitialized() { + return initialized.get(); + } + + /** + * Refreshes the cache by re-scanning the project. + * This should be called at project startup and when the project model changes. + */ + public void refresh() { + try { + if (project.isDisposed()) { + return; + } + log.info("Refreshing Java upgrade issues cache for project: {}", project.getName()); + final JavaUpgradeIssuesDetectionService detectionService = JavaUpgradeIssuesDetectionService.getInstance(); + + // Scan for issues + jdkIssuesCache = detectionService.getJavaIssues(project); + log.info("Detected {} JDK issues", jdkIssuesCache.size()); + dependencyIssuesCache= detectionService.getDependencyIssues(project); + log.info("Detected {} dependency issues", dependencyIssuesCache.size()); + cvesIssuesCache = detectionService.getCVEIssues(project); + log.info("Detected {} CVE issues", cvesIssuesCache.size()); + + initialized.set(true); + } catch (Throwable e) { + log.error("Error refreshing Java upgrade issues cache for project: {}", project.getName(), e); + } + } + + /** + * Invalidates the cache, forcing a refresh on next access. + */ + public void invalidate() { + jdkIssuesCache = null; + dependencyIssuesCache = null; + cvesIssuesCache = null; + initialized.set(false); + } + + @Override + public void dispose() { + invalidate(); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaUpgradeIssuesDetectionService.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaUpgradeIssuesDetectionService.java new file mode 100644 index 00000000000..d8499172a0b --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaUpgradeIssuesDetectionService.java @@ -0,0 +1,673 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service; + +import com.intellij.openapi.project.Project; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.intellij.common.utils.JdkUtils; +import com.microsoft.intellij.util.GradleUtils; +import com.microsoft.intellij.util.MavenUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.maven.artifact.versioning.ComparableVersion; +import org.jetbrains.idea.maven.model.MavenArtifact; +import org.jetbrains.idea.maven.model.MavenArtifactNode; +import org.jetbrains.idea.maven.project.MavenProject; +import org.jetbrains.idea.maven.project.MavenProjectsManager; +import org.jetbrains.plugins.gradle.model.ExternalDependency; +import org.jetbrains.plugins.gradle.model.ExternalProject; +import org.jetbrains.plugins.gradle.model.ExternalSourceSet; +import org.jetbrains.plugins.gradle.model.UnresolvedExternalDependency; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.*; + +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.ISSUE_DISPLAY_NAME; + +/** + * Service to detect JDK version and framework dependency versions in Java projects. + * This service analyzes the project to identify outdated versions that may need upgrading. + * + * This implementation is aligned with the TypeScript version in vscode-java-dependency. + * @see assessmentManager.ts + */ +@Slf4j +public class JavaUpgradeIssuesDetectionService { + + /** + * The mature LTS version of Java that is recommended. + * Aligned with MATURE_JAVA_LTS_VERSION from vscode-java-dependency. + */ + public static final int MATURE_JAVA_LTS_VERSION = 21; + + // Group ID constants for Spring dependencies + public static final String GROUP_ID_SPRING_BOOT = "org.springframework.boot"; + public static final String GROUP_ID_SPRING_FRAMEWORK = "org.springframework"; + public static final String GROUP_ID_SPRING_SECURITY = "org.springframework.security"; + + // Artifact ID constants + public static final String ARTIFACT_ID_SPRING_BOOT_STARTER_PARENT = "spring-boot-starter-parent"; + public static final String ARTIFACT_ID_MAVEN_COMPILER_PLUGIN = "maven-compiler-plugin"; + + // Package ID constants (used for cache lookups) + public static final String PACKAGE_ID_JDK = "jdk"; + public static final String JDK_DISPLAY_NAME = "JDK"; + + /** + * Metadata for dependencies to scan. + * Aligned with DEPENDENCIES_TO_SCAN from vscode-java-dependency. + */ + public static class DependencyCheckItem { + @Nonnull public final String groupId; + @Nonnull public final String artifactId; + @Nonnull public final String displayName; + @Nonnull public final String supportedVersion; + @Nonnull public final String suggestedVersion; + @Nonnull public final String learnMoreUrl; + @Nonnull public final Map eolDate; + + public DependencyCheckItem(@Nonnull String groupId, @Nonnull String artifactId, @Nonnull String displayName, + @Nonnull String supportedVersion, @Nonnull String suggestedVersion, @Nonnull String learnMoreUrl, + @Nonnull Map eolDate) { + this.groupId = groupId; + this.artifactId = artifactId; + this.displayName = displayName; + this.supportedVersion = supportedVersion; + this.suggestedVersion = suggestedVersion; + this.learnMoreUrl = learnMoreUrl; + this.eolDate = eolDate; + } + + public String getPackageId() { + return groupId + ":" + artifactId; + } + + /** + * Gets the EOL date for a specific version. + * @param version The version to check (e.g., "2.7.x", "3.5.x") + * @return The EOL date string (e.g., "2029-06") or null if not found + */ + @Nullable + public String getEolDateForVersion(@Nonnull String version) { + // Try exact match first + if (eolDate.containsKey(version)) { + return eolDate.get(version); + } + // Try to match major.minor.x pattern + String[] parts = version.split("\\."); + if (parts.length >= 2) { + String pattern = parts[0] + "." + parts[1] + ".x"; + return eolDate.get(pattern); + } + return null; + } + } + + /** + * Dependencies to scan for upgrade issues. + * Aligned with DEPENDENCIES_TO_SCAN from dependency.metadata.ts in vscode-java-dependency. + */ + private static final List DEPENDENCIES_TO_SCAN = List.of( + // Spring Boot - supported versions: 2.7.x or >=3.2.x + new DependencyCheckItem( + GROUP_ID_SPRING_BOOT, + "*", + "Spring Boot", + "2.7.x || >=3.2.x", + "3.5", + "https://spring.io/projects/spring-boot#support", + Map.ofEntries( + Map.entry("4.0.x", "2027-12"), + Map.entry("3.5.x", "2032-06"), + Map.entry("3.4.x", "2026-12"), + Map.entry("3.3.x", "2026-06"), + Map.entry("3.2.x", "2025-12"), + Map.entry("3.1.x", "2025-06"), + Map.entry("3.0.x", "2024-12"), + Map.entry("2.7.x", "2029-06"), + Map.entry("2.6.x", "2024-02"), + Map.entry("2.5.x", "2023-08"), + Map.entry("2.4.x", "2023-02"), + Map.entry("2.3.x", "2022-08"), + Map.entry("2.2.x", "2022-01"), + Map.entry("2.1.x", "2021-01"), + Map.entry("2.0.x", "2020-06"), + Map.entry("1.5.x", "2020-11") + ) + ), + // Spring Framework - supported versions: 5.3.x or >=6.2.x + new DependencyCheckItem( + GROUP_ID_SPRING_FRAMEWORK, + "*", + "Spring Framework", + "5.3.x || >=6.2.x", + "6.2", + "https://spring.io/projects/spring-framework#support", + Map.ofEntries( + Map.entry("7.0.x", "2028-06"), + Map.entry("6.2.x", "2032-06"), + Map.entry("6.1.x", "2026-06"), + Map.entry("6.0.x", "2025-08"), + Map.entry("5.3.x", "2029-06"), + Map.entry("5.2.x", "2023-12"), + Map.entry("5.1.x", "2022-12"), + Map.entry("5.0.x", "2022-12"), + Map.entry("4.3.x", "2020-12") + ) + ), + // Spring Security - supported versions: 5.7.x || 5.8.x || >=6.2.x + new DependencyCheckItem( + GROUP_ID_SPRING_SECURITY, + "*", + "Spring Security", + "5.7.x || 5.8.x || >=6.2.x", + "6.5", + "https://spring.io/projects/spring-security#support", + Map.ofEntries( + Map.entry("7.0.x", "2027-12"), + Map.entry("6.5.x", "2032-06"), + Map.entry("6.4.x", "2026-12"), + Map.entry("6.3.x", "2026-06"), + Map.entry("6.2.x", "2025-12"), + Map.entry("6.1.x", "2025-06"), + Map.entry("6.0.x", "2024-12"), + Map.entry("5.8.x", "2029-06"), + Map.entry("5.7.x", "2029-06"), + Map.entry("5.6.x", "2024-02"), + Map.entry("5.5.x", "2023-08"), + Map.entry("5.4.x", "2023-02"), + Map.entry("5.3.x", "2022-08"), + Map.entry("5.2.x", "2022-01"), + Map.entry("5.1.x", "2021-01"), + Map.entry("5.0.x", "2020-06"), + Map.entry("4.2.x", "2020-11") + ) + ) + ); + + private static final String JDK_LEARN_MORE_URL = + "https://learn.microsoft.com/azure/developer/java/fundamentals/java-support-on-azure"; + + private static JavaUpgradeIssuesDetectionService instance; + + /** Formatter for parsing EOL dates in "yyyy-MM" format */ + private static final DateTimeFormatter EOL_DATE_PARSER = DateTimeFormatter.ofPattern("yyyy-MM"); + + /** Formatter for displaying EOL dates in "MMMM yyyy" format (e.g., "June 2020") */ + private static final DateTimeFormatter EOL_DATE_DISPLAY = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH); + + private JavaUpgradeIssuesDetectionService() { + } + + public static synchronized JavaUpgradeIssuesDetectionService getInstance() { + if (instance == null) { + instance = new JavaUpgradeIssuesDetectionService(); + } + return instance; + } + + /** + * Formats an EOL date from "yyyy-MM" format to "Month yyyy" format. + * For example, "2020-06" becomes "June 2020". + * + * @param eolDate The EOL date string in "yyyy-MM" format (e.g., "2020-06") + * @return The formatted date string (e.g., "June 2020"), or the original string if parsing fails + */ + @Nonnull + public static String formatEolDate(@Nonnull String eolDate) { + try { + YearMonth yearMonth = YearMonth.parse(eolDate, EOL_DATE_PARSER); + return yearMonth.format(EOL_DATE_DISPLAY); + } catch (Exception e) { + // If parsing fails, return the original string + log.error("Error formatting EOL date '{}': {}", eolDate, e.getMessage()); + return eolDate; + } + } + + /** + * Gets JDK/JRE version issues. + * Aligned with getJavaIssues() from assessmentManager.ts. + */ + @Nonnull + public List getJavaIssues(@Nonnull Project project) { + final List issues = new ArrayList<>(); + + try { + final Integer jdkVersion = JdkUtils.getJdkLanguageLevel(project); + log.info("Got JDK version: {}", jdkVersion); + AppModUtils.logTelemetryEvent("getJavaVersion", Map.of("jdkVersion", String.valueOf(jdkVersion))); + if (jdkVersion == null) { + return issues; + } + + // Skip versions below 8 - out of scope + if (jdkVersion < 8) { + AppModUtils.logTelemetryEvent("getJavaVersionSkipped", Map.of("jdkVersion", String.valueOf(jdkVersion))); + log.warn("JDK version below 8 detected ({}), skipping JDK upgrade check", jdkVersion); + return issues; + } + + // Check against MATURE_JAVA_LTS_VERSION (21) + if (jdkVersion < MATURE_JAVA_LTS_VERSION) { + issues.add(JavaUpgradeIssue.builder() + .packageId(PACKAGE_ID_JDK) + .packageDisplayName(JDK_DISPLAY_NAME) + .upgradeReason(JavaUpgradeIssue.UpgradeReason.JRE_TOO_OLD) + .severity(JavaUpgradeIssue.Severity.WARNING) + .currentVersion(String.valueOf(jdkVersion)) + .supportedVersion(">=" + MATURE_JAVA_LTS_VERSION) + .suggestedVersion(String.valueOf(MATURE_JAVA_LTS_VERSION)) + .message(String.format(ISSUE_DISPLAY_NAME, JDK_DISPLAY_NAME, jdkVersion, JDK_DISPLAY_NAME, MATURE_JAVA_LTS_VERSION)) + .learnMoreUrl(JDK_LEARN_MORE_URL) + .build()); + } + } catch (Exception e) { + // Error checking JDK version + log.error("Error checking JDK version: {}", e.getMessage(), e); + } + + return issues; + } + + /** + * Gets dependency issues by checking against DEPENDENCIES_TO_SCAN metadata. + * Aligned with getDependencyIssue() from assessmentManager.ts. + */ + @Nonnull + public List getDependencyIssues(@Nonnull Project project) { + final List issues = new ArrayList<>(); + + try { + final Set checkedPackages = new HashSet<>(); + + if (MavenUtils.isMavenProject(project)) { + log.info("Checking Maven project dependencies for upgrade issues"); + final MavenProjectsManager mavenProjectsManager = MavenProjectsManager.getInstanceIfCreated(project); + if (mavenProjectsManager != null && mavenProjectsManager.isMavenizedProject()) { + final List mavenProjects = mavenProjectsManager.getProjects(); + + for (MavenProject mavenProject : mavenProjects) { + for (DependencyCheckItem checkItem : DEPENDENCIES_TO_SCAN) { + if (checkedPackages.contains(checkItem.getPackageId())) { + continue; + } + + final JavaUpgradeIssue issue = checkDependency(mavenProject, checkItem, checkedPackages); + if (issue != null) { + issues.add(issue); + } + } + } + } + } else if (GradleUtils.isGradleProject(project)) { + log.info("Checking Gradle project dependencies for upgrade issues"); + final List gradleProjects = GradleUtils.listGradleProjects(project); + for (ExternalProject gradleProject : gradleProjects) { + for (DependencyCheckItem checkItem : DEPENDENCIES_TO_SCAN) { + if (checkedPackages.contains(checkItem.getPackageId())) { + continue; + } + final JavaUpgradeIssue issue = checkGradleDependency(gradleProject, checkItem, checkedPackages); + if (issue != null) { + issues.add(issue); + } + } + } + } + + } catch (Exception e) { + // Error checking dependencies + log.error("Error checking dependency issues: {}", e.getMessage(), e); + } + + return issues; + } + + /** + * Gets CVE (Common Vulnerabilities and Exposures) issues for project dependencies. + * Aligned with getCVEIssues() from assessmentManager.ts. + * + * @param project The IntelliJ project to analyze + * @return List of CVE-related upgrade issues + */ + @Nonnull + public List getCVEIssues(@Nonnull Project project) { + try { + final Set coordinateSet = new HashSet<>(); + + if (MavenUtils.isMavenProject(project)) { + log.info("Checking Maven project dependencies for CVE issues"); + final MavenProjectsManager mavenProjectsManager = MavenProjectsManager.getInstanceIfCreated(project); + if (mavenProjectsManager != null && mavenProjectsManager.isMavenizedProject()) { + final List mavenProjects = mavenProjectsManager.getProjects(); + + for (MavenProject mavenProject : mavenProjects) { + // Get direct dependencies only (root level of dependency tree) + mavenProject.getDependencyTree().stream() + .map(MavenArtifactNode::getArtifact) + .filter(dep -> StringUtils.isNotBlank(dep.getVersion())) + .forEach(dep -> coordinateSet.add( + dep.getGroupId() + ":" + dep.getArtifactId() + ":" + dep.getVersion() + )); + } + } + } else if (GradleUtils.isGradleProject(project)) { + log.info("Checking Gradle project dependencies for CVE issues"); + final List gradleProjects = GradleUtils.listGradleProjects(project); + for (ExternalProject gradleProject : gradleProjects) { + final ExternalSourceSet main = gradleProject.getSourceSets().get("main"); + if (main != null) { + main.getDependencies().stream() + .filter(dep -> !(dep instanceof UnresolvedExternalDependency)) + .filter(dep -> StringUtils.isNotBlank(dep.getVersion())) + .forEach(dep -> coordinateSet.add( + dep.getGroup() + ":" + dep.getName() + ":" + dep.getVersion() + )); + } + } + } + + if (coordinateSet.isEmpty()) { + return Collections.emptyList(); + } + + // Check CVEs for all collected dependencies + final List coordinates = new ArrayList<>(coordinateSet); + log.info("Checking CVE issues for {} dependencies", coordinates.size()); + return CVECheckService.getInstance().batchGetCVEIssues(coordinates); + + } catch (Exception e) { + // Error checking CVE issues + log.error("Error checking CVE issues: {}", e.getMessage(), e); + return Collections.emptyList(); + } + } + + /** + * Checks a single dependency against its metadata. + * Aligned with the logic in getDependencyIssue() from assessmentManager.ts. + */ + @Nullable + private JavaUpgradeIssue checkDependency(@Nonnull MavenProject mavenProject, + @Nonnull DependencyCheckItem checkItem, + @Nonnull Set checkedPackages) { + String version = null; + + // Special handling for Spring Boot parent POM + if (GROUP_ID_SPRING_BOOT.equals(checkItem.groupId)) { + version = getParentVersion(mavenProject, checkItem.groupId, ARTIFACT_ID_SPRING_BOOT_STARTER_PARENT); + } + + // If not found in parent, check direct dependencies + if (version == null) { + String targetArtifactId = "*".equals(checkItem.artifactId) ? null : checkItem.artifactId; + final MavenArtifact dependency = findDirectDependency(mavenProject, checkItem.groupId, targetArtifactId); + if (dependency != null) { + version = dependency.getVersion(); + } + } + + if (version == null || StringUtils.isBlank(version)) { + return null; + } + + checkedPackages.add(checkItem.getPackageId()); + + // Check if version satisfies the supported version range + if (!satisfiesVersionRange(version, checkItem.supportedVersion) && isVersionEndOfLife(version, checkItem)) { + return JavaUpgradeIssue.builder() + .packageId(checkItem.getPackageId()) + .packageDisplayName(checkItem.displayName) + .upgradeReason(determineUpgradeReason(version, checkItem)) + .severity(determineSeverity(version, checkItem)) + .currentVersion(version) + .supportedVersion(checkItem.supportedVersion) + .suggestedVersion(checkItem.suggestedVersion) + .message(buildUpgradeMessage(checkItem.displayName, version, checkItem)) + .learnMoreUrl(checkItem.learnMoreUrl) + .eofDate(checkItem.getEolDateForVersion(version)) + .build(); + } + + return null; + } + + /** + * Gets the version from parent POM. + */ + @Nullable + private String getParentVersion(@Nonnull MavenProject mavenProject, + @Nonnull String groupId, + @Nonnull String artifactId) { + try { + final var parentId = mavenProject.getParentId(); + if (parentId != null && + groupId.equals(parentId.getGroupId()) && + artifactId.equals(parentId.getArtifactId())) { + return parentId.getVersion(); + } + } catch (Exception e) { + // Error getting parent version + log.error("Error getting parent version for {}:{} - {}", groupId, artifactId, e.getMessage(), e); + } + return null; + } + + /** + * Checks if a version satisfies a version range. + * Supports ranges like: "2.7.x || >=3.2", ">=10", "5.3.x || >=6.1" + * Aligned with semver logic from assessmentManager.ts. + */ + private boolean satisfiesVersionRange(@Nonnull String version, @Nonnull String range) { + // Split by "||" for OR conditions + final String[] orConditions = range.split("\\|\\|"); + + for (String condition : orConditions) { + condition = condition.trim(); + + if (satisfiesSingleCondition(version, condition)) { + return true; + } + } + + return false; + } + + /** + * Checks if a version satisfies a single version condition. + */ + private boolean satisfiesSingleCondition(@Nonnull String version, @Nonnull String condition) { + try { + // Handle ">=" pattern (check before ".x" pattern to handle ">=3.2.x" correctly) + if (condition.startsWith(">=")) { + String minVersion = condition.substring(2).trim(); + // Handle version with wildcard, e.g. ">=3.2.x" -> "3.2" + if (minVersion.endsWith(".x")) { + minVersion = minVersion.substring(0, minVersion.length() - 2); + } + final ComparableVersion current = new ComparableVersion(version); + final ComparableVersion min = new ComparableVersion(minVersion); + return current.compareTo(min) >= 0; + } + + // Handle ">" pattern (check before ".x" pattern to handle ">3.2.x" correctly) + if (condition.startsWith(">")) { + String minVersion = condition.substring(1).trim(); + // Handle version with wildcard, e.g. ">3.2.x" -> "3.2" + if (minVersion.endsWith(".x")) { + minVersion = minVersion.substring(0, minVersion.length() - 2); + } + final ComparableVersion current = new ComparableVersion(version); + final ComparableVersion min = new ComparableVersion(minVersion); + return current.compareTo(min) > 0; + } + + // Handle "x.y.x" pattern (e.g., "2.7.x" means any 2.7.*) + if (condition.endsWith(".x")) { + final String prefix = condition.substring(0, condition.length() - 2); + return version.startsWith(prefix + "."); + } + + // Handle exact version match + return version.equals(condition); + + } catch (Exception e) { + // Error checking version range + log.error("Error checking version '{}' against condition '{}': {}", version, condition, e.getMessage(), e); + return false; + } + } + + /** + * Checks if a version has reached its end-of-life date based on the EOL map. + * @param version The version to check (e.g., "2.0.1.RELEASE") + * @param checkItem The dependency check item containing EOL dates + * @return true if the version is past its EOL date + */ + private boolean isVersionEndOfLife(@Nonnull String version, @Nonnull DependencyCheckItem checkItem) { + String eolDateStr = checkItem.getEolDateForVersion(version); + if (eolDateStr == null) { + return false; + } + + try { + // Parse EOL date (format: "YYYY-MM") + java.time.YearMonth eolDate = java.time.YearMonth.parse(eolDateStr); + java.time.YearMonth currentDate = java.time.YearMonth.now(); + return currentDate.isAfter(eolDate); + } catch (Exception e) { + log.error("Error parsing EOL date '{}': {}", eolDateStr, e.getMessage()); + return false; + } + } + + /** + * Gets the EOL date string for a version if available. + */ + @Nullable + private String getEolDateString(@Nonnull String version, @Nonnull DependencyCheckItem checkItem) { + return checkItem.getEolDateForVersion(version); + } + + /** + * Determines the upgrade reason based on version and EOL status. + */ + @Nonnull + private JavaUpgradeIssue.UpgradeReason determineUpgradeReason(@Nonnull String version, + @Nonnull DependencyCheckItem checkItem) { + // Check if version has reached EOL based on the EOL date map + if (isVersionEndOfLife(version, checkItem)) { + return JavaUpgradeIssue.UpgradeReason.END_OF_LIFE; + } + + // For deprecated but still maintained versions + return JavaUpgradeIssue.UpgradeReason.DEPRECATED; + } + + /** + * Determines the severity based on version and EOL status. + */ + @Nonnull + private JavaUpgradeIssue.Severity determineSeverity(@Nonnull String version, + @Nonnull DependencyCheckItem checkItem) { + // If version has reached EOL, mark as critical + if (isVersionEndOfLife(version, checkItem)) { + return JavaUpgradeIssue.Severity.INFO; + } + + // For other unsupported versions (not yet EOL but outside supported range) + return JavaUpgradeIssue.Severity.INFO; + } + + /** + * Builds a human-readable upgrade message. + */ + @Nonnull + private String buildUpgradeMessage(@Nonnull String displayName, + @Nonnull String currentVersion, + @Nonnull DependencyCheckItem checkItem) { + return String.format( + ISSUE_DISPLAY_NAME, + displayName, currentVersion, displayName, checkItem.suggestedVersion + ); + } + + /** + * Finds a direct dependency in the Maven project (excludes transitive dependencies). + * Uses getDependencyTree() to identify only dependencies explicitly declared in pom.xml. + * This aligns with the TypeScript implementation which parses pom.xml directly. + */ + @Nullable + private MavenArtifact findDirectDependency(@Nonnull MavenProject mavenProject, + @Nonnull String groupId, + @Nullable String artifactId) { + // getDependencyTree() returns the root-level nodes which are direct dependencies + // (transitive dependencies are children of these nodes) + List dependencyTree = mavenProject.getDependencyTree(); + return dependencyTree.stream() + .map(MavenArtifactNode::getArtifact) + .filter(dep -> groupId.equals(dep.getGroupId())) + .filter(dep -> artifactId == null || artifactId.equals(dep.getArtifactId())) + .findFirst() + .orElse(null); + } + + /** + * Checks a single dependency against its metadata for Gradle projects. + */ + @Nullable + private JavaUpgradeIssue checkGradleDependency(@Nonnull ExternalProject gradleProject, + @Nonnull DependencyCheckItem checkItem, + @Nonnull Set checkedPackages) { + final ExternalSourceSet main = gradleProject.getSourceSets().get("main"); + if (main == null) { + return null; + } + + // Find direct dependency + final ExternalDependency dependency = main.getDependencies().stream() + .filter(dep -> StringUtils.equalsIgnoreCase(checkItem.groupId, dep.getGroup()) && + ("*".equals(checkItem.artifactId) || StringUtils.equalsIgnoreCase(checkItem.artifactId, dep.getName()))) + .filter(dep -> !(dep instanceof UnresolvedExternalDependency)) + .findFirst() + .orElse(null); + + if (dependency == null) { + return null; + } + + final String version = dependency.getVersion(); + + if (version == null || StringUtils.isBlank(version)) { + return null; + } + + checkedPackages.add(checkItem.getPackageId()); + + // Check if version satisfies the supported version range + if (!satisfiesVersionRange(version, checkItem.supportedVersion)) { + return JavaUpgradeIssue.builder() + .packageId(checkItem.getPackageId()) + .packageDisplayName(checkItem.displayName) + .upgradeReason(determineUpgradeReason(version, checkItem)) + .severity(determineSeverity(version, checkItem)) + .currentVersion(version) + .supportedVersion(checkItem.supportedVersion) + .suggestedVersion(checkItem.suggestedVersion) + .message(buildUpgradeMessage(checkItem.displayName, version, checkItem)) + .learnMoreUrl(checkItem.learnMoreUrl) + .eofDate(checkItem.getEolDateForVersion(version)) + .build(); + } + + return null; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaVersionNotificationService.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaVersionNotificationService.java new file mode 100644 index 00000000000..71aab4abe3c --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/service/JavaVersionNotificationService.java @@ -0,0 +1,524 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service; + +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.ide.util.PropertiesComponent; +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationAction; +import com.intellij.notification.NotificationType; +import com.intellij.notification.Notifications; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DataContext; +import com.intellij.openapi.extensions.PluginId; +import com.intellij.openapi.project.Project; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.dao.JavaUpgradeIssue; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.lib.common.task.AzureTaskManager; + +import static com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller.*; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils.Constants.*; +import static com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaUpgradeIssuesDetectionService.*; +import java.lang.reflect.Method; + +import kotlin.Unit; +import kotlin.jvm.functions.Function1; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import com.github.copilot.api.CopilotChatService; +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Service to display notifications about outdated Java project versions. + * Notifications appear in the bottom-right corner of the IDE. + * Only shows one notification at a time (the first detected issue). + */ +@Slf4j +public class JavaVersionNotificationService { + + private static final String NOTIFICATION_GROUP_ID = "Azure Toolkit - Java Version Check"; + private static final String NOTIFICATIONS_ENABLED_KEY = "azure.toolkit.java.version.notifications.enabled"; + private static final String DEFERRED_UNTIL_KEY = "azure.toolkit.java.version.deferred_until"; + private static final long DEFER_INTERVAL_MS = 10 * 24 * 60 * 60 * 1000L; // 10 days in milliseconds + private static final String DEFAULT_MODEL_NAME = "Claude Sonnet 4.5"; + + // GitHub Copilot plugin ID + private static final String COPILOT_PLUGIN_ID = "com.github.copilot"; + + private static JavaVersionNotificationService instance; + + private JavaVersionNotificationService() { + } + + public static synchronized JavaVersionNotificationService getInstance() { + if (instance == null) { + instance = new JavaVersionNotificationService(); + } + return instance; + } + + /** + * Shows a notification for the first outdated version issue. + * Only shows if notifications are enabled for the Java upgrade feature. + * + * @param project The project context + * @param issues List of detected issues + */ + public void showNotifications(@Nonnull Project project, @Nonnull List issues) { + if (project.isDisposed() || issues.isEmpty()) { + return; + } + + // Check if notifications are enabled for this feature + if (!isNotificationsEnabled(project)) { + log.info("Java upgrade notifications are disabled."); + return; + } + + // Check if we should skip based on timing (deferred) + if (!shouldCheckNow(project)) { + log.info("Java upgrade notifications are deferred until later."); + return; + } + + // Only show notification for the first issue + final JavaUpgradeIssue firstIssue = issues.get(0); + showNotification(project, firstIssue); + } + + /** + * Shows a single notification for an outdated version issue. + */ + private void showNotification(@Nonnull Project project, + @Nonnull JavaUpgradeIssue issue) { + final NotificationType notificationType = getNotificationType(issue.getSeverity()); + String title = issue.getTitle(); + if (issue.getEofDate() != null){ + //change 2020-06 to June 2020 + String formattedDate = formatEolDate(issue.getEofDate()); + title = String.format("%s (%s)", title, formattedDate); + } + final Notification notification = new Notification( + NOTIFICATION_GROUP_ID, + title, + formatMessage(issue), + notificationType + ); + String issueStr = issue.toString(); + if (isAppModPluginInstalled()) { + // Plugin is installed - show "Upgrade" action + AppModUtils.logTelemetryEvent("showNotification.install.appmod", Map.of("javaupgrade.issue", issueStr)); + notification.addAction(new NotificationAction("Upgrade") { + @Override + public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { + openCopilotChatWithUpgradePrompt(project, issue); + } + }); + } else { + // Plugin is not installed - show "Install and Upgrade" action + AppModUtils.logTelemetryEvent("showNotification.upgrade", Map.of("javaupgrade.issue", issueStr)); + notification.addAction(new NotificationAction("Install and Upgrade") { + @Override + public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { + AppModPluginInstaller.showInstallConfirmation(project, true, () -> AppModPluginInstaller.installPlugin(project, true)); + } + }); + } + + + // Add "Not Now" action - defers the notification for 10 days + notification.addAction(new NotificationAction("Not Now") { + @Override + public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { + deferNotifications(); + notification.expire(); + } + }); + + // Add "Don't Show Again" action - disables the entire Java upgrade notification feature + notification.addAction(new NotificationAction("Don't Show Again") { + @Override + public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { + setNotificationsEnabled(false); + notification.expire(); + } + }); + + // Show the notification + Notifications.Bus.notify(notification, project); + } + + /** + * Formats the notification message with HTML for better display. + */ + private String formatMessage(@Nonnull JavaUpgradeIssue issue) { + final StringBuilder sb = new StringBuilder(); + sb.append(""); + if (isAppModPluginInstalled()){ + sb.append(issue.getMessage()); + } else { + sb.append(issue.getMessage() + TO_INSTALL_APP_MODE_PLUGIN); + } + sb.append("."); + +// if (issue.getCurrentVersion() != null && issue.getSuggestedVersion() != null) { +// sb.append("

"); +// sb.append("Current: ").append(issue.getCurrentVersion()); +// sb.append(" → Suggested: ").append(issue.getSuggestedVersion()); +// } + + sb.append(""); + return sb.toString(); + } + + /** + * Gets the notification type based on issue severity. + */ + private NotificationType getNotificationType(@Nonnull JavaUpgradeIssue.Severity severity) { + return switch (severity) { + case CRITICAL -> NotificationType.ERROR; + case WARNING -> NotificationType.WARNING; + case INFO -> NotificationType.INFORMATION; + }; + } + + /** + * Generates a unique key for an issue to track dismissals. + */ + private String getIssueKey(@Nonnull JavaUpgradeIssue issue) { + return issue.getPackageId() + ":" + + issue.getUpgradeReason().name() + ":" + + Objects.requireNonNullElse(issue.getCurrentVersion(), "unknown"); + } + + /** + * Checks if Java upgrade notifications are enabled globally. + * @return true if notifications are enabled (default), false otherwise + */ + public boolean isNotificationsEnabled() { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + return properties.getBoolean(NOTIFICATIONS_ENABLED_KEY, true); + } + + /** + * Checks if Java upgrade notifications are enabled for the project. + * Uses application-level setting. + * @param project The project (for backwards compatibility, not used) + * @return true if notifications are enabled (default), false otherwise + */ + public boolean isNotificationsEnabled(@Nonnull Project project) { + return isNotificationsEnabled(); + } + + /** + * Enables or disables Java upgrade notifications globally. + * @param enabled true to enable notifications, false to disable + */ + public void setNotificationsEnabled(boolean enabled) { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + properties.setValue(NOTIFICATIONS_ENABLED_KEY, enabled, true); + } + + /** + * Enables or disables Java upgrade notifications. + * Uses application-level setting. + * @param project The project (for backwards compatibility, not used) + * @param enabled true to enable notifications, false to disable + */ + public void setNotificationsEnabled(@Nonnull Project project, boolean enabled) { + setNotificationsEnabled(enabled); + } + + /** + * Checks if the notification should be shown now. + * Returns false if the user has clicked "Not Now" and the defer period hasn't passed. + */ + private boolean shouldCheckNow(@Nonnull Project project) { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + final long deferredUntil = properties.getLong(DEFERRED_UNTIL_KEY, 0); + final long now = System.currentTimeMillis(); + + // If we're still in the deferred period, don't show notification + return now >= deferredUntil; + } + + /** + * Defers notifications for 10 days. + * Called when user clicks "Not Now". + */ + public void deferNotifications() { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + final long deferUntil = System.currentTimeMillis() + DEFER_INTERVAL_MS; + properties.setValue(DEFERRED_UNTIL_KEY, String.valueOf(deferUntil)); + } + + /** + * Gets the timestamp until which notifications are deferred. + * @return The deferred-until timestamp in milliseconds, or 0 if not deferred + */ + public long getDeferredUntil() { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + return properties.getLong(DEFERRED_UNTIL_KEY, 0); + } + + /** + * Clears the deferred notification state. + */ + public void clearDeferral() { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + properties.unsetValue(DEFERRED_UNTIL_KEY); + } + + /** + * Checks if upgrade is supported for this issue type. + */ + private boolean isUpgradeSupported(@Nonnull JavaUpgradeIssue issue) { + // Upgrade support for JDK and Spring Boot + return issue.getUpgradeReason() == JavaUpgradeIssue.UpgradeReason.JRE_TOO_OLD || + issue.getPackageId().startsWith(GROUP_ID_SPRING_BOOT + ":"); + } + + /** + * Opens GitHub Copilot chat in agent mode with an upgrade prompt. + * @param project The project context + * @param issue The upgrade issue to address + */ + private void openCopilotChatWithUpgradePrompt(@Nonnull Project project, @Nonnull JavaUpgradeIssue issue) { + final String prompt = buildUpgradePrompt(issue); + openCopilotChatWithPrompt(project, prompt); + } + + /** + * Opens GitHub Copilot chat in agent mode with a given prompt. + * Tries direct API first, falls back to reflection for cross-version compatibility. + * @param project The project context + * @param prompt The prompt to send to Copilot + */ + public void openCopilotChatWithPrompt(@Nonnull Project project, @Nonnull String prompt) { + try { + AzureTaskManager.getInstance().runLater(() -> { + if (!isAppModPluginInstalled()) { + // showGenericUpgradeGuidance(project, prompt); + AppModPluginInstaller.showInstallConfirmation(project, true, () -> AppModPluginInstaller.installPlugin(project, true)); + return; + } + +// //TODO Try direct API call first (works when plugin versions match) +// if (tryDirectCopilotCall(project, prompt)) { +// return; // Success, no need for reflection +// } + + // Fallback to reflection for cross-version compatibility + if (tryReflectionCopilotCall(project, prompt)) { + return; // Success via reflection + } + + // Both approaches failed + log.info("Failed to open Copilot chat via both direct and reflection methods."); + showGenericUpgradeGuidance(project, prompt); + }); + } catch (Exception e) { + log.error("Error opening Copilot chat: " + e.getMessage()); + showGenericUpgradeGuidance(project, prompt); + } + } + + /** + * Tries to call CopilotChatService directly (works when compile-time and runtime versions match). + * @return true if successful, false if an error occurred + */ + private boolean tryDirectCopilotCall(@Nonnull Project project, @Nonnull String prompt) { + try { + CopilotChatService service = project.getService(CopilotChatService.class); + if (service != null) { + service.query(DataContext.EMPTY_CONTEXT, builder -> { + builder.withInput(prompt); + builder.withAgentMode(); + builder.withNewSession(); + withModelCompatibility(builder, DEFAULT_MODEL_NAME); + builder.withSessionIdReceiver(sessionId -> null); + return null; + }); + return true; + } + } catch (Error | Exception e) { + // Direct call failed (version mismatch, class not found, etc.) - will try reflection + log.info("Direct Copilot call failed: " + e.getMessage()); + } + return false; + } + + /** + * Tries to call CopilotChatService via reflection for cross-version compatibility. + * @return true if successful, false if an error occurred + */ + private boolean tryReflectionCopilotCall(@Nonnull Project project, @Nonnull String prompt) { + try { + // Get the Copilot plugin's classloader to load its classes + final IdeaPluginDescriptor copilotPlugin = PluginManagerCore.getPlugin(PluginId.getId(COPILOT_PLUGIN_ID)); + if (copilotPlugin == null || !copilotPlugin.isEnabled()) { + return false; + } + + final ClassLoader copilotClassLoader = copilotPlugin.getPluginClassLoader(); + if (copilotClassLoader == null) { + return false; + } + + // Use reflection to load CopilotChatService from the Copilot plugin's classloader + Class copilotChatServiceClass = copilotClassLoader.loadClass("com.github.copilot.api.CopilotChatService"); + Object service = project.getService(copilotChatServiceClass); + + if (service != null) { + // Find the query method dynamically - signature may vary across Copilot versions + Method queryMethod = findQueryMethod(copilotChatServiceClass); + if (queryMethod == null) { + return false; + } + + // Use Kotlin Function1 since the Copilot API is written in Kotlin + Function1 queryBuilder = builder -> { + try { + builder.getClass().getMethod("withInput", String.class).invoke(builder, prompt); + builder.getClass().getMethod("withAgentMode").invoke(builder); + builder.getClass().getMethod("withNewSession").invoke(builder); + withModelCompatibility(builder, DEFAULT_MODEL_NAME); + Method withSessionIdReceiverMethod = findMethodByName(builder.getClass(), "withSessionIdReceiver"); + if (withSessionIdReceiverMethod != null) { + Function1 sessionIdReceiver = sessionId -> Unit.INSTANCE; + withSessionIdReceiverMethod.invoke(builder, sessionIdReceiver); + } + } catch (Exception ex) { + // Error configuring query builder via reflection + log.error("Error configuring Copilot query via reflection: " + ex.getMessage()); + } + return Unit.INSTANCE; + }; + queryMethod.invoke(service, DataContext.EMPTY_CONTEXT, queryBuilder); + return true; + } + } catch (Exception e) { + // Reflection call failed + log.error("Reflection Copilot call failed: " + e.getMessage()); + } + return false; + } + + /** + * Shows generic guidance for upgrading when Copilot is not available. + * @param project The project context + * @param prompt The upgrade prompt that would be used + */ + private void showGenericUpgradeGuidance(@Nonnull Project project, @Nonnull String prompt) { + final String guidance = String.format( + "Open GitHub Copilot chat and use the following prompt in agent mode:\n\n%s", prompt); + + final Notification guidanceNotification = new Notification( + NOTIFICATION_GROUP_ID, + "Upgrade Guidance", + guidance, + NotificationType.INFORMATION + ); + + Notifications.Bus.notify(guidanceNotification, project); + } + + /** + * Sets the model for the query builder using reflection for compatibility with older versions of GitHub Copilot. + * Note: The API 'withModel' is supported starting from Copilot version '1.5.63'. + * @param builder the query option builder + * @param modelName the name of the model to set + */ + private static void withModelCompatibility(Object builder, String modelName) { + try { + builder.getClass().getMethod("withModel", String.class).invoke(builder, modelName); + } catch (NoSuchMethodException ex) { + // Method withModel not found in QueryOptionBuilder, skipping + } catch (Exception ex) { + // Error calling withModel via reflection, can be ignored + log.error("Error setting model via reflection: " + ex.getMessage()); + } + } + + /** + * Finds the 'query' method in CopilotChatService dynamically. + * The method signature may vary across Copilot versions. + * @param serviceClass The CopilotChatService class + * @return The query method, or null if not found + */ + private Method findQueryMethod(Class serviceClass) { + for (Method method : serviceClass.getMethods()) { + if ("query".equals(method.getName()) && method.getParameterCount() == 2) { + Class[] paramTypes = method.getParameterTypes(); + // Look for query(DataContext, Function/Consumer/etc) + if (DataContext.class.isAssignableFrom(paramTypes[0])) { + return method; + } + } + } + return null; + } + + /** + * Finds a method by name in a class (first match). + * @param clazz The class to search + * @param methodName The method name to find + * @return The method, or null if not found + */ + private Method findMethodByName(Class clazz, String methodName) { + for (Method method : clazz.getMethods()) { + if (methodName.equals(method.getName())) { + return method; + } + } + return null; + } + + /** + * Builds the upgrade prompt for Copilot based on the issue type. + * @param issue The upgrade issue + * @return The prompt string for Copilot + */ + private String buildUpgradePrompt(@Nonnull JavaUpgradeIssue issue) { + if (issue.getUpgradeReason() == JavaUpgradeIssue.UpgradeReason.CVE){ + return String.format(FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_PROMPT, issue.getPackageId()); + } + return String.format(UPGRADE_JAVA_FRAMEWORK_PROMPT, issue.getPackageDisplayName(), issue.getCurrentVersion(), issue.getSuggestedVersion()); + } + + /** + * Shows guidance for upgrading the project when Copilot is not available. + * @param project The project context + * @param issue The upgrade issue + * @param prompt The upgrade prompt that would be used + */ + private void showUpgradeGuidance(@Nonnull Project project, @Nonnull JavaUpgradeIssue issue, @Nonnull String prompt) { + showGenericUpgradeGuidance(project, prompt); + } + + /** + * Resets notification settings (useful for testing or reset). + * Re-enables notifications and clears the deferral. + */ + public void resetNotificationSettings() { + final PropertiesComponent properties = PropertiesComponent.getInstance(); + properties.unsetValue(NOTIFICATIONS_ENABLED_KEY); + properties.unsetValue(DEFERRED_UNTIL_KEY); + } + + /** + * Resets notification settings for a project (for backwards compatibility). + * @param project The project (not used, settings are application-level) + */ + public void resetNotificationSettings(@Nonnull Project project) { + resetNotificationSettings(); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/settings/JavaUpgradeConfigurable.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/settings/JavaUpgradeConfigurable.java new file mode 100644 index 00000000000..2a9cbacf37f --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/settings/JavaUpgradeConfigurable.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.settings; + +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.util.NlsContexts; +import com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.service.JavaVersionNotificationService; + +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.awt.*; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; + +/** + * Settings page for Java Upgrade feature. + * Allows users to enable/disable notifications and reset deferral settings. + * + * Accessible via: Settings → Tools → Azure Toolkit → Java Upgrade + */ +public class JavaUpgradeConfigurable implements Configurable { + + private JPanel mainPanel; + private JCheckBox enableNotificationsCheckBox; + private JButton resetDeferralButton; + private JLabel deferralStatusLabel; + + @Override + public @NlsContexts.ConfigurableName String getDisplayName() { + return "Java Upgrade"; + } + + @Override + public @Nullable JComponent createComponent() { + mainPanel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.anchor = GridBagConstraints.NORTHWEST; + gbc.insets = new Insets(2, 0, 2, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + + // Title label + gbc.gridx = 0; + gbc.gridy = 0; + JLabel titleLabel = new JLabel("Java Upgrade Notifications"); + mainPanel.add(titleLabel, gbc); + + // Description + gbc.gridy = 1; + JLabel descriptionLabel = new JLabel("Configure notifications for outdated JDK versions, " + + "framework versions, and security vulnerabilities."); + mainPanel.add(descriptionLabel, gbc); + + // Enable notifications checkbox + gbc.gridy = 2; + enableNotificationsCheckBox = new JCheckBox("Show notifications for Java upgrade issues"); + enableNotificationsCheckBox.setToolTipText( + "When enabled, balloon notifications will appear when outdated JDK or framework versions are detected."); + mainPanel.add(enableNotificationsCheckBox, gbc); + + // Deferral section subtitle (indented) + gbc.gridy = 3; + gbc.insets = new Insets(2, 20, 2, 5); + JLabel deferralTitle = new JLabel("Notification Deferral"); + mainPanel.add(deferralTitle, gbc); + + // Deferral status (indented) + gbc.gridy = 4; + deferralStatusLabel = new JLabel(); + updateDeferralStatusLabel(); + mainPanel.add(deferralStatusLabel, gbc); + + // Reset deferral button (indented) + gbc.gridy = 5; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.WEST; + resetDeferralButton = new JButton("Reset Deferral"); + resetDeferralButton.setToolTipText("Clear the \"Not Now\" deferral and allow notifications to show immediately."); + resetDeferralButton.addActionListener(e -> { + JavaVersionNotificationService.getInstance().clearDeferral(); + updateDeferralStatusLabel(); + }); + mainPanel.add(resetDeferralButton, gbc); + + // Spacer to push content to the top + gbc.gridy = 6; + gbc.insets = new Insets(2, 0, 2, 5); + gbc.weighty = 1.0; + gbc.fill = GridBagConstraints.BOTH; + mainPanel.add(new JPanel(), gbc); + + return mainPanel; + } + + private void updateDeferralStatusLabel() { + if (deferralStatusLabel == null || resetDeferralButton == null) { + return; + } + final JavaVersionNotificationService service = JavaVersionNotificationService.getInstance(); + final long deferredUntil = service.getDeferredUntil(); + final long now = System.currentTimeMillis(); + + if (deferredUntil > now) { + // Notifications are deferred + SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy HH:mm"); + String dateStr = dateFormat.format(new Date(deferredUntil)); + deferralStatusLabel.setText("Notifications deferred until: " + dateStr + ""); + resetDeferralButton.setEnabled(true); + } else { + deferralStatusLabel.setText("Notifications are not deferred."); + resetDeferralButton.setEnabled(false); + } + } + + @Override + public boolean isModified() { + if (enableNotificationsCheckBox == null) { + return false; + } + final JavaVersionNotificationService service = JavaVersionNotificationService.getInstance(); + return enableNotificationsCheckBox.isSelected() != service.isNotificationsEnabled(); + } + + @Override + public void apply() throws ConfigurationException { + if (enableNotificationsCheckBox == null) { + return; + } + final JavaVersionNotificationService service = JavaVersionNotificationService.getInstance(); + service.setNotificationsEnabled(enableNotificationsCheckBox.isSelected()); + AppModUtils.logTelemetryEvent("applyJavaUpgradeNotificationSettings", Map.of("notificationsEnabled", String.valueOf(enableNotificationsCheckBox.isSelected()))); + } + + @Override + public void reset() { + if (enableNotificationsCheckBox == null) { + return; + } + final JavaVersionNotificationService service = JavaVersionNotificationService.getInstance(); + enableNotificationsCheckBox.setSelected(service.isNotificationsEnabled()); + updateDeferralStatusLabel(); + AppModUtils.logTelemetryEvent("resetJavaUpgradeNotificationDeferralSettings"); + } + + @Override + public void disposeUIResources() { + mainPanel = null; + enableNotificationsCheckBox = null; + resetDeferralButton = null; + deferralStatusLabel = null; + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/utils/Constants.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/utils/Constants.java new file mode 100644 index 00000000000..78bca711e25 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/utils/Constants.java @@ -0,0 +1,16 @@ +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils; + +public class Constants { + public static final String UPGRADE_JAVA_AND_FRAMEWORK_PROMPT = "Upgrade java runtime and java framework dependencies of this project to the latest LTS version using java upgrade tools by invoking #generate_upgrade_plan"; + public static final String UPGRADE_JAVA_VERSION_PROMPT = "Upgrade Java runtime from version %s to Java %s (LTS) using java upgrade tools by invoking #generate_upgrade_plan"; + public static final String UPGRADE_JAVA_FRAMEWORK_PROMPT = "Upgrade %s from version %s to %s using java upgrade tools by invoking #generate_upgrade_plan"; + public static final String SCAN_AND_RESOLVE_CVES_PROMPT = "run CVE scan for this project using java upgrade tools by invoking #validate_cves_for_java"; + public static final String UPGRADE_JDK_WITH_COPILOT_DISPLAY_NAME = "Upgrade JDK with Copilot"; + public static final String UPGRADE_SPRING_BOOT_WITH_COPILOT_DISPLAY_NAME = "Upgrade Spring Boot with Copilot"; + public static final String UPGRADE_SPRING_FRAMEWORK_WITH_COPILOT_DISPLAY_NAME = "Upgrade Spring Framework with Copilot"; + public static final String UPGRADE_SPRING_SECURITY_WITH_COPILOT_DISPLAY_NAME = "Upgrade Spring Security with Copilot"; + public static final String SCAN_AND_RESOLVE_CVES_WITH_COPILOT_DISPLAY_NAME = "Scan and Resolve CVEs with Copilot"; + public static final String FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_PROMPT = "Fix the vulnerable dependency %s by using #validate_cves_for_java"; + public static final String FIX_VULNERABLE_DEPENDENCY_WITH_COPILOT_DISPLAY_NAME = "Fix the vulnerable dependency with Copilot"; + public static final String ISSUE_DISPLAY_NAME = "Your project uses %s %s. Consider upgrading to %s %s or higher for better performance and support"; +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/utils/PomXmlUtils.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/utils/PomXmlUtils.java new file mode 100644 index 00000000000..b36c28e2c03 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/javaupgrade/utils/PomXmlUtils.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.utils; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Utility class for parsing and extracting information from pom.xml files. + */ +public final class PomXmlUtils { + + private static final int MAX_SEARCH_OFFSET = 500; + + private PomXmlUtils() { + // Utility class, no instantiation + } + + /** + * Finds the start of the dependency block containing the given offset. + * + * @param text the full text of the pom.xml file + * @param offset the current cursor offset + * @return the start index of the dependency block, or -1 if not found + */ + public static int findDependencyStart(@NotNull String text, int offset) { + // Look for tag before the offset + int searchStart = Math.max(0, offset - MAX_SEARCH_OFFSET); + String searchArea = text.substring(searchStart, offset); + int lastDependency = searchArea.lastIndexOf(""); + if (lastDependency >= 0) { + return searchStart + lastDependency; + } + return -1; + } + + /** + * Finds the end of the dependency block containing the given offset. + * + * @param text the full text of the pom.xml file + * @param offset the current cursor offset + * @return the end index of the dependency block (after closing tag), or -1 if not found + */ + public static int findDependencyEnd(@NotNull String text, int offset) { + // Look for tag after the offset + int searchEnd = Math.min(text.length(), offset + MAX_SEARCH_OFFSET); + String searchArea = text.substring(offset, searchEnd); + int endDependency = searchArea.indexOf(""); + if (endDependency >= 0) { + return offset + endDependency + "".length(); + } + return -1; + } + + /** + * Extracts a value from an XML tag. + * + * @param xml the XML string to search in + * @param tagName the name of the tag to extract the value from + * @return the value inside the tag, or null if not found + */ + @Nullable + public static String extractXmlValue(@NotNull String xml, @NotNull String tagName) { + final String startTag = "<" + tagName + ">"; + final String endTag = ""; + + int start = xml.indexOf(startTag); + if (start < 0) { + return null; + } + start += startTag.length(); + + int end = xml.indexOf(endTag, start); + if (end < 0) { + return null; + } + + return xml.substring(start, end).trim(); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/AppModPanelHelper.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/AppModPanelHelper.java new file mode 100644 index 00000000000..b84084136c5 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/AppModPanelHelper.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.utils; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowManager; +import com.microsoft.azure.toolkit.lib.common.messager.AzureMessager; + +import javax.annotation.Nonnull; + +/** + * Helper class for opening the App Modernization Panel. + */ +public final class AppModPanelHelper { + + public static final String TOOL_WINDOW_ID = "GitHub Copilot app modernization"; + + private AppModPanelHelper() { + // Utility class + } + + /** + * Opens the App Modernization Panel tool window. + * + * @param project the current project + * @param source the source of the open request (e.g., "node", "action", "facet") + */ + public static void openAppModPanel(@Nonnull Project project, @Nonnull String source) { + final ToolWindowManager toolWindowManager = ToolWindowManager.getInstance(project); + final ToolWindow toolWindow = toolWindowManager.getToolWindow(TOOL_WINDOW_ID); + + if (toolWindow != null) { + AppModUtils.logTelemetryEvent(source + ".open-panel"); + toolWindow.show(); + } else { + AppModUtils.logTelemetryEvent(source + ".open-panel-failed"); + AzureMessager.getMessager().warning("App Modernization Panel is not available. Please ensure the GitHub Copilot App Modernization plugin is installed and enabled."); + } + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/AppModUtils.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/AppModUtils.java new file mode 100644 index 00000000000..b114b7f3f85 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/AppModUtils.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.appmod.utils; + +import com.intellij.openapi.diagnostic.Logger; +import com.microsoft.azure.toolkit.lib.common.task.AzureTaskManager; +import com.microsoft.azure.toolkit.lib.common.telemetry.AzureTelemeter; +import com.microsoft.azure.toolkit.lib.common.telemetry.AzureTelemetry; + +import javax.annotation.Nonnull; +import java.util.Map; + +/** + * Utility class for App Modernization (Migrate to Azure) telemetry and common operations. + */ +public final class AppModUtils { + + private static final Logger LOG = Logger.getInstance(AppModUtils.class); + private static final String SERVICE_NAME = "appmod"; + // Set to true to enable telemetry debug logging (for development only) + private static final boolean DEBUG_TELEMETRY = false; + + private AppModUtils() { + // Utility class, no instantiation allowed + } + + /** + * Logs a telemetry event for App Modernization features. + * + * @param eventName The name of the event to log (e.g., "show-node", "click-option", "refresh") + */ + public static void logTelemetryEvent(@Nonnull String eventName) { + if (DEBUG_TELEMETRY) { + LOG.info("[AppMod Telemetry] " + eventName); + } + final Map properties = Map.of( + AzureTelemeter.OP_NAME, eventName, + AzureTelemeter.OP_PARENT_ID, SERVICE_NAME, + AzureTelemeter.OPERATION_NAME, eventName, + AzureTelemeter.SERVICE_NAME, SERVICE_NAME + ); + AzureTaskManager.getInstance().runLater(() -> { + AzureTelemeter.log(AzureTelemetry.Type.INFO, properties); + }); + } + + /** + * Logs a telemetry event with additional properties. + * + * @param eventName The name of the event to log + * @param additionalProperties Additional properties to include in the telemetry + */ + public static void logTelemetryEvent(@Nonnull String eventName, @Nonnull Map additionalProperties) { + if (DEBUG_TELEMETRY) { + LOG.info("[AppMod Telemetry] " + eventName + " " + additionalProperties); + } + final Map properties = new java.util.HashMap<>(Map.of( + AzureTelemeter.OP_NAME, eventName, + AzureTelemeter.OP_PARENT_ID, SERVICE_NAME, + AzureTelemeter.OPERATION_NAME, eventName, + AzureTelemeter.SERVICE_NAME, SERVICE_NAME + )); + properties.putAll(additionalProperties); + AzureTaskManager.getInstance().runLater(() -> { + AzureTelemeter.log(AzureTelemetry.Type.INFO, properties); + }); + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/Constants.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/Constants.java new file mode 100644 index 00000000000..8d1bf611621 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/java/com/microsoft/azure/toolkit/intellij/appmod/utils/Constants.java @@ -0,0 +1,7 @@ +package com.microsoft.azure.toolkit.intellij.appmod.utils; + +public class Constants { + + public static final String ICON_APPMOD_PATH = "/icons/appmod.svg"; + +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/META-INF/azure-intellij-plugin-appmod.xml b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/META-INF/azure-intellij-plugin-appmod.xml new file mode 100644 index 00000000000..29c2ad3de56 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/META-INF/azure-intellij-plugin-appmod.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + XML + com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action.CveFixDependencyIntentionAction + Azure Toolkit + + + XML + com.microsoft.azure.toolkit.intellij.appmod.javaupgrade.action.CveFixIntentionAction + Azure Toolkit + + + + + + + + + + + + + + + + + + + + + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/icons/appmod.svg b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/icons/appmod.svg new file mode 100644 index 00000000000..0558ebd7969 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/icons/appmod.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/icons/appmod_dark.svg b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/icons/appmod_dark.svg new file mode 100644 index 00000000000..f44426f34f1 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/icons/appmod_dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/after.xml.template b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/after.xml.template new file mode 100644 index 00000000000..47d971d72b9 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/after.xml.template @@ -0,0 +1,5 @@ + + org.example + vulnerable-library + 2.0.0 + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/before.xml.template b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/before.xml.template new file mode 100644 index 00000000000..b1bef2c076a --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/before.xml.template @@ -0,0 +1,5 @@ + + org.example + vulnerable-library + 1.0.0 + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/description.html b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/description.html new file mode 100644 index 00000000000..9225dc45325 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixDependencyIntentionAction/description.html @@ -0,0 +1,13 @@ + + +Fixes CVE vulnerabilities in Maven dependencies using GitHub Copilot. +

+When the cursor is positioned on a dependency in pom.xml that has known CVE issues, +this intention action opens GitHub Copilot Chat with a predefined prompt to help identify and fix +the security vulnerabilities in that specific dependency. +

+

+This action is available only for Maven pom.xml files and requires GitHub Copilot and GitHub Copilot app modernization to be installed. +

+ + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/after.xml.template b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/after.xml.template new file mode 100644 index 00000000000..47d971d72b9 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/after.xml.template @@ -0,0 +1,5 @@ + + org.example + vulnerable-library + 2.0.0 + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/before.xml.template b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/before.xml.template new file mode 100644 index 00000000000..b1bef2c076a --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/before.xml.template @@ -0,0 +1,5 @@ + + org.example + vulnerable-library + 1.0.0 + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/description.html b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/description.html new file mode 100644 index 00000000000..67067b85919 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-appmod/src/main/resources/intentionDescriptions/CveFixIntentionAction/description.html @@ -0,0 +1,13 @@ + + +Scans and resolves CVE vulnerabilities in Maven dependencies using java upgrade tools. +

+When the cursor is positioned on a dependency in pom.xml that has known CVE issues, +this intention action opens GitHub Copilot Chat with a predefined prompt to help identify and fix +the security vulnerabilities. +

+

+This action is available only for Maven pom.xml files and requires GitHub Copilot and Github Copilot app modernization to be installed. +

+ + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-base/src/main/resources/META-INF/plugin.xml b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-base/src/main/resources/META-INF/plugin.xml index 308a49b55f0..5f24c40ff44 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-base/src/main/resources/META-INF/plugin.xml +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-base/src/main/resources/META-INF/plugin.xml @@ -30,6 +30,7 @@ org.jetbrains.idea.maven com.intellij.gradle + diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-lib/src/main/java/com/microsoft/azure/toolkit/intellij/common/IntelliJAzureIcons.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-lib/src/main/java/com/microsoft/azure/toolkit/intellij/common/IntelliJAzureIcons.java index b6efe061ec0..4d5a4f93f11 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-lib/src/main/java/com/microsoft/azure/toolkit/intellij/common/IntelliJAzureIcons.java +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-lib/src/main/java/com/microsoft/azure/toolkit/intellij/common/IntelliJAzureIcons.java @@ -32,6 +32,7 @@ public class IntelliJAzureIcons { put("/icons/spinner", AnimatedIcon.Default.INSTANCE); put("/icons/error", AllIcons.General.Error); put("/icons/unknown", AllIcons.Nodes.Unknown); + put("/icons/changelist", AllIcons.Vcs.Changelist); } }; private static final Map azureIcons = new ConcurrentHashMap<>() { diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/build.gradle.kts b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/build.gradle.kts index 2bcc6e6fb46..dbed828fbb9 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/build.gradle.kts +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/build.gradle.kts @@ -1,5 +1,7 @@ dependencies { implementation(project(":azure-intellij-plugin-lib")) // runtimeOnly project(path: ":azure-intellij-plugin-lib", configuration: "instrumentedJar") + implementation(project(":azure-intellij-plugin-appmod")) + // runtimeOnly project(path: ":azure-intellij-plugin-appmod", configuration: "instrumentedJar") implementation("com.microsoft.azure:azure-toolkit-ide-common-lib") } diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/src/main/java/com/microsoft/azure/toolkit/intellij/explorer/AzureExplorer.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/src/main/java/com/microsoft/azure/toolkit/intellij/explorer/AzureExplorer.java index 1b450e7ad6a..9f13607d864 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/src/main/java/com/microsoft/azure/toolkit/intellij/explorer/AzureExplorer.java +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-service-explorer/src/main/java/com/microsoft/azure/toolkit/intellij/explorer/AzureExplorer.java @@ -32,6 +32,7 @@ import com.microsoft.azure.toolkit.intellij.common.component.Tree; import com.microsoft.azure.toolkit.intellij.common.component.TreeUtils; import com.microsoft.azure.toolkit.intellij.explorer.azd.AzdNode; +import com.microsoft.azure.toolkit.intellij.appmod.javamigration.MigrateToAzureNode; import com.microsoft.azure.toolkit.lib.Azure; import com.microsoft.azure.toolkit.lib.auth.AzureAccount; import com.microsoft.azure.toolkit.lib.auth.IAccountActions; @@ -50,12 +51,10 @@ import javax.annotation.Nonnull; import javax.swing.tree.DefaultTreeModel; -import javax.swing.tree.TreeNode; import javax.swing.tree.TreePath; import java.awt.event.MouseEvent; import java.util.Arrays; import java.util.Comparator; -import java.util.Enumeration; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -72,17 +71,20 @@ public class AzureExplorer extends Tree { public static final AzureExplorerNodeProviderManager manager = new AzureExplorerNodeProviderManager(); public static final String AZURE_ICON = AzureIcons.Common.AZURE.getIconPath(); private final AzdNode azdNode; + private final MigrateToAzureNode migrateToAzureNode; private AzureExplorer(Project project) { super(); this.putClientProperty(PLACE, ResourceCommonActionsContributor.AZURE_EXPLORER); this.azdNode = new AzdNode(project); + this.migrateToAzureNode = new MigrateToAzureNode(project); this.root = new Node<>("Azure") .withChildrenLoadLazily(false) .addChild(buildFavoriteRoot()) .addChild(buildAppGroupedResourcesRoot()) .addChild(buildTypeGroupedResourcesRoot()) .addChildren(buildNonAzServiceNodes()) + .addChild(migrateToAzureNode) .addChild(azdNode); this.init(this.root); diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/build.gradle.kts b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/build.gradle.kts index 216c059e068..25e794a25ff 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/build.gradle.kts +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/build.gradle.kts @@ -1,6 +1,8 @@ dependencies { implementation(project(":azure-intellij-plugin-lib")) // runtimeOnly project(path: ":azure-intellij-plugin-lib", configuration: "instrumentedJar") + implementation(project(":azure-intellij-plugin-appmod")) + // runtimeOnly project(path: ":azure-intellij-plugin-appmod", configuration: "instrumentedJar") implementation(project(":azure-intellij-plugin-service-explorer")) // runtimeOnly project(path: ":azure-intellij-plugin-service-explorer", configuration: "instrumentedJar") implementation("com.microsoft.azure:azure-toolkit-ide-common-lib") diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetRootNode.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetRootNode.java index a526cac550f..e907ba27829 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetRootNode.java +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetRootNode.java @@ -94,10 +94,12 @@ public Collection> buildChildren() { if (Objects.isNull(profile)) { nodes.add(new ActionNode<>(this.getProject(), CONNECT_TO_MODULE, module)); nodes.add(new ActionNode<>(this.getProject(), Action.Id.of(ACTIONS_DEPLOY_TO_AZURE), module.getModule())); - return nodes; + } else { + nodes.add(new DeploymentTargetsNode(this.getProject(), profile.getDeploymentTargetManager())); + nodes.add(new ConnectionsNode(this.getProject(), profile.getConnectionManager())); } - nodes.add(new DeploymentTargetsNode(this.getProject(), profile.getDeploymentTargetManager())); - nodes.add(new ConnectionsNode(this.getProject(), profile.getConnectionManager())); + // Always add Migrate to Azure node at the end + nodes.add(new MigrateToAzureFacetNode(this.getProject(), module)); return nodes; } diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetTreeStructureProvider.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetTreeStructureProvider.java index 0a96d288749..59fd6ad9e64 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetTreeStructureProvider.java +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/AzureFacetTreeStructureProvider.java @@ -179,12 +179,12 @@ private void updatePopupMenuActions(final IAzureFacetNode node) { final ActionManager manager = ActionManager.getInstance(); final DefaultActionGroup popupMenu = (DefaultActionGroup) manager.getAction("ProjectViewPopupMenu"); if (this.currentNode == null && CollectionUtils.isEmpty(backupActions)) { - this.backupActions = Arrays.stream(popupMenu.getChildren((AnActionEvent) null)).collect(Collectors.toList()); + this.backupActions = Arrays.stream(popupMenu.getChildren(manager)).collect(Collectors.toList()); } final List actions = Optional.ofNullable(node.getActionGroup()).map(IActionGroup::getActions).orElse(Collections.emptyList()); final IntellijAzureActionManager.ActionGroupWrapper wrapper = new IntellijAzureActionManager.ActionGroupWrapper(new ActionGroup(actions)); popupMenu.removeAll(); - Arrays.stream(wrapper.getChildren((AnActionEvent) null)).forEach(popupMenu::add); + Arrays.stream(wrapper.getChildren(manager)).forEach(popupMenu::add); } } diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/MigrateToAzureFacetNode.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/MigrateToAzureFacetNode.java new file mode 100644 index 00000000000..efc9017de65 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-resource-connector-lib/src/main/java/com/microsoft/azure/toolkit/intellij/connector/projectexplorer/MigrateToAzureFacetNode.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.connector.projectexplorer; + +import com.intellij.icons.AllIcons; +import com.intellij.ide.projectView.PresentationData; +import com.intellij.openapi.extensions.ExtensionPointName; +import com.intellij.openapi.project.Project; +import com.intellij.ui.tree.LeafState; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModPanelHelper; +import com.microsoft.azure.toolkit.intellij.appmod.utils.AppModUtils; +import com.microsoft.azure.toolkit.intellij.appmod.utils.Constants; +import com.microsoft.azure.toolkit.intellij.common.IntelliJAzureIcons; +import com.microsoft.azure.toolkit.intellij.connector.dotazure.AzureModule; +import com.microsoft.azure.toolkit.intellij.appmod.javamigration.IMigrateOptionProvider; +import com.microsoft.azure.toolkit.intellij.appmod.javamigration.MigrateNodeData; +import com.microsoft.azure.toolkit.intellij.appmod.common.AppModPluginInstaller; +import com.microsoft.azure.toolkit.ide.common.icon.AzureIcons; +import com.microsoft.azure.toolkit.lib.common.action.Action; +import com.microsoft.azure.toolkit.lib.common.action.ActionGroup; +import com.microsoft.azure.toolkit.lib.common.action.IActionGroup; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Project Explorer facet node for "Migrate to Azure" functionality. + * Uses the same extension point as MigrateToAzureNode and MigrateToAzureAction for consistency. + * + * State is computed on initialization and refreshed when buildChildren() is called (via tree refresh). + */ +@Slf4j +public class MigrateToAzureFacetNode extends AbstractAzureFacetNode { + private static final ExtensionPointName migrationProviders = + ExtensionPointName.create("com.microsoft.tooling.msservices.intellij.azure.migrateOptionProvider"); + + // Lazy-loaded state - computed on first access + private List migrationNodes = null; + + public MigrateToAzureFacetNode(Project project, AzureModule module) { + super(project, module); + log.debug("[MigrateToAzureFacetNode] Creating node for project: {}", project.getName()); + // Don't compute in constructor - use lazy loading + } + + /** + * Gets migration nodes, computing them lazily on first access. + */ + private List getMigrationNodes() { + if (migrationNodes == null) { + log.debug("[MigrateToAzureFacetNode] getMigrationNodes - computing (first access)"); + migrationNodes = computeMigrationNodes(); + } + return migrationNodes; + } + + /** + * Computes migration nodes from extension point providers. + */ + private List computeMigrationNodes() { + log.debug("[MigrateToAzureFacetNode] computeMigrationNodes - appModInstalled: {}", AppModPluginInstaller.isAppModPluginInstalled()); + try { + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + return List.of(); + } + final List nodes = migrationProviders.getExtensionList().stream() + .filter(provider -> provider.isApplicable(getProject())) + .sorted(Comparator.comparingInt(IMigrateOptionProvider::getPriority)) + .flatMap(provider -> provider.createNodeData(getProject()).stream()) + .filter(MigrateNodeData::isVisible) + .collect(Collectors.toList()); + log.debug("[MigrateToAzureFacetNode] computeMigrationNodes - loaded {} nodes", nodes.size()); + if (nodes.isEmpty()) { + AppModUtils.logTelemetryEvent("facet.no-tasks"); + } + return nodes; + } catch (Exception e) { + log.error("[MigrateToAzureFacetNode] Failed to compute migration nodes", e); + return List.of(); + } + } + + /** + * Checks if there are any visible migration options available. + * Only returns true if data has already been loaded (non-blocking). + */ + private boolean hasMigrationOptions() { + // Only check cached data - don't trigger loading on UI thread + return migrationNodes != null && !migrationNodes.isEmpty(); + } + + /** + * Checks if migration nodes have been loaded. + */ + private boolean isMigrationNodesLoaded() { + return migrationNodes != null; + } + + /** + * Refreshes migration nodes and updates the tree view. + */ + public void refresh() { + log.debug("[MigrateToAzureFacetNode] refresh called"); + migrationNodes = null; // Clear cached data to force recompute + updateChildren(); // This also refreshes the view + } + + @Nullable + @Override + public IActionGroup getActionGroup() { + final Action refreshAction = new Action<>(Action.Id.of("user/appmod.refresh_migrate_node")) + .withLabel("Refresh") + .withIcon(AzureIcons.Action.REFRESH.getIconPath()) + .withHandler((v, e) -> { + AppModUtils.logTelemetryEvent("facet.refresh"); + refresh(); + }) + .withAuthRequired(false); + return new ActionGroup(refreshAction); + } + + @Override + public Collection> buildChildren() { + final ArrayList> nodes = new ArrayList<>(); + + // Convert MigrateNodeData to FacetNode + for (MigrateNodeData nodeData : getMigrationNodes()) { + nodes.add(new MigrationNodeWrapper(getProject(), nodeData)); + } + + return nodes; + } + + @Override + protected void buildView(@Nonnull PresentationData presentation) { + presentation.setIcon(IntelliJAzureIcons.getIcon(Constants.ICON_APPMOD_PATH)); + + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + final boolean copilotInstalled = AppModPluginInstaller.isCopilotInstalled(); + final String text = copilotInstalled + ? "Migrate to Azure (Install Github Copilot app modernization)" + : "Migrate to Azure (Install GitHub Copilot and app modernization)"; + presentation.addText(text, com.intellij.ui.SimpleTextAttributes.REGULAR_ATTRIBUTES); + } else if (isMigrationNodesLoaded() && !hasMigrationOptions()) { + // Only show "Open..." if we've already loaded and found no options + presentation.addText("Migrate to Azure", com.intellij.ui.SimpleTextAttributes.REGULAR_ATTRIBUTES); + presentation.setLocationString("Open GitHub Copilot app modernization"); + } else { + // Default state or has options + presentation.addText("Migrate to Azure", com.intellij.ui.SimpleTextAttributes.REGULAR_ATTRIBUTES); + } + } + + @Override + public void navigate(boolean requestFocus) { + log.debug("[MigrateToAzureFacetNode] navigate - appModInstalled: {}, hasMigrationOptions: {}", + AppModPluginInstaller.isAppModPluginInstalled(), hasMigrationOptions()); + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + // Plugin not installed - trigger install on double-click + log.info("[MigrateToAzureFacetNode] Install click triggered"); + AppModUtils.logTelemetryEvent("facet.click-install"); + AppModPluginInstaller.showInstallConfirmation(getProject(), false, + () -> AppModPluginInstaller.installPlugin(getProject(), false)); + } else if (isMigrationNodesLoaded() && !hasMigrationOptions()) { + // No migration options - open App Modernization Panel + log.info("[MigrateToAzureFacetNode] Opening AppMod panel (no options)"); + AppModPanelHelper.openAppModPanel(getProject(), "facet"); + } + } + + @Override + public boolean canNavigate() { + // Enable navigation when plugin is not installed OR when loaded and no migration options + return !AppModPluginInstaller.isAppModPluginInstalled() || (isMigrationNodesLoaded() && !hasMigrationOptions()); + } + + @Override + public @Nonnull LeafState getLeafState() { + // Use ASYNC to avoid triggering extension point loading synchronously + // The actual leaf state will be determined when buildChildren() is called + if (!AppModPluginInstaller.isAppModPluginInstalled()) { + return LeafState.ALWAYS; + } + // ASYNC means IntelliJ will call buildChildren() to determine if there are children + return LeafState.ASYNC; + } + + /** + * Wrapper class that converts MigrateNodeData to AbstractAzureFacetNode for Project Explorer display. + */ + private static class MigrationNodeWrapper extends AbstractAzureFacetNode { + private final MigrateNodeData nodeData; + + protected MigrationNodeWrapper(Project project, MigrateNodeData nodeData) { + super(project, nodeData); + this.nodeData = nodeData; + } + + @Override + public Collection> buildChildren() { + final ArrayList> children = new ArrayList<>(); + + // Get children - lazy or static (Project Explorer's buildChildren is already lazy) + final List childDataList = nodeData.isLazyLoading() + ? nodeData.getChildrenLoader().get() + : nodeData.getChildren(); + + for (MigrateNodeData child : childDataList) { + if (child.isVisible()) { + children.add(new MigrationNodeWrapper(getProject(), child)); + } + } + + return children; + } + + @Override + protected void buildView(@Nonnull PresentationData presentation) { + presentation.addText(nodeData.getLabel(), com.intellij.ui.SimpleTextAttributes.REGULAR_ATTRIBUTES); + + // Set tooltip if available + if (nodeData.getDescription() != null) { + presentation.setTooltip(nodeData.getDescription()); + } + + // Use node's icon if available, otherwise use default app_mod icon + presentation.setIcon(AllIcons.Vcs.Changelist); + } + + @Override + public void navigate(boolean requestFocus) { + // Trigger click handler + AppModUtils.logTelemetryEvent("facet.click-task", java.util.Map.of("label", nodeData.getLabel())); + nodeData.doubleClick(null); + } + + @Override + public boolean canNavigate() { + // Enable navigation for leaf nodes OR nodes with click handlers + return !nodeData.hasChildren() || nodeData.hasClickHandler(); + } + + @Override + public boolean canNavigateToSource() { + // Enable source navigation only for leaf nodes + return !nodeData.hasChildren(); + } + + @Override + public @Nonnull LeafState getLeafState() { + // ALWAYS = leaf node (no expand arrow, double-click triggers navigate) + // NEVER = always show expand arrow + return nodeData.hasChildren() ? LeafState.NEVER : LeafState.ALWAYS; + } + } +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/settings.gradle.kts b/PluginsAndFeatures/azure-toolkit-for-intellij/settings.gradle.kts index 8457ab5446d..e8bb0fa2905 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/settings.gradle.kts +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/settings.gradle.kts @@ -51,3 +51,4 @@ include("azure-intellij-plugin-integration-services") include("azure-intellij-plugin-java-sdk") include("azure-intellij-plugin-cloud-shell") include("azure-intellij-plugin-azuremcp") +include("azure-intellij-plugin-appmod") diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/src/main/resources/META-INF/plugin.xml b/PluginsAndFeatures/azure-toolkit-for-intellij/src/main/resources/META-INF/plugin.xml index aa845630aad..b221ed6cb3b 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/src/main/resources/META-INF/plugin.xml +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/src/main/resources/META-INF/plugin.xml @@ -44,6 +44,7 @@ + @@ -112,6 +113,8 @@ + +