diff --git a/.github/workflows/IJ-latest.yml b/.github/workflows/IJ-latest.yml index c4d7b4f8..90091332 100644 --- a/.github/workflows/IJ-latest.yml +++ b/.github/workflows/IJ-latest.yml @@ -15,10 +15,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' - name: Grant execute permission for gradlew diff --git a/.github/workflows/IJ.yml b/.github/workflows/IJ.yml index 4fc75aee..92964646 100644 --- a/.github/workflows/IJ.yml +++ b/.github/workflows/IJ.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' - name: Grant execute permission for gradlew diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9fdfed6..19b4de9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' - name: Grant execute permission for gradlew @@ -34,10 +34,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' - name: Grant execute permission for gradlew @@ -53,10 +53,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' - name: Grant execute permission for gradlew diff --git a/.github/workflows/publish-marketplace.yml b/.github/workflows/publish-marketplace.yml index e205f6b8..a6e9e825 100644 --- a/.github/workflows/publish-marketplace.yml +++ b/.github/workflows/publish-marketplace.yml @@ -24,10 +24,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f7a1906..ca743e08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'gradle' - name: Grant execute permission for gradlew diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index 6c5e7fab..1754842f 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -17,10 +17,10 @@ jobs: - name: Checkout sources uses: actions/checkout@v4 - - name: Install Java 17 + - name: Install Java 21 uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'oracle' cache: 'gradle' diff --git a/README.md b/README.md index b8a533c5..6740382c 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,12 @@ according to your preferences. - **Proxy Configuration** :
From IntelliJ IDEA Appearance & Behavior > System Settings > HTTP Proxy, you can configure a static proxy for all HTTP requests made by the plugin. This is useful when your environment requires going through a proxy to access external services. For example:`http://proxy.example.com:8080` +- **Batch Analysis** : +
Configure settings for batch workspace analysis, which analyzes all packages in a JS/TS monorepo or Cargo workspace at once. +
**Concurrency**: Maximum number of concurrent analyses during batch workspace analysis (default: 10). +
**Continue on Error**: Continue analyzing remaining packages when one package analysis fails (default: enabled). +
**Include Metadata**: Include additional metadata in batch analysis results (default: enabled). + - **Manifest Exclusion Patterns** :
You can exclude manifest files from component analysis using glob patterns. This is useful for excluding third-party dependencies, test files, or other manifests that should not be analyzed.
Enter one pattern per line. Examples: `**/node_modules/**/package.json` to exclude all package.json files in node_modules directories, or `test/**/pom.xml` to exclude all Maven files in test directories. @@ -396,6 +402,16 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
Right-click on any manifest file and select **Exclude from Component Analysis** to quickly add an exclusion pattern for that specific file. +- **Batch Workspace Analysis** +
For JS/TS monorepos (npm, pnpm, yarn workspaces) and Cargo workspaces, you can run a batch analysis that scans + all packages in the workspace at once and generates a combined vulnerability report. +
To run a batch analysis, right-click in the **Project** window and click **Batch Workspace Analysis Report**. +
The workspace root must contain a supported workspace layout: + - JS/TS workspace: `package.json` with a lock file (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) + - Cargo workspace: `Cargo.toml` with `Cargo.lock` +
Exclusion patterns configured in **Manifest Exclusion Patterns** are applied during workspace discovery to skip + specific packages from the batch analysis. + - **Red Hat Dependency Analytics report**
The Red Hat Dependency Analytics report is a temporary HTML file that exist if the **Red Hat Dependency Analytics Report** tab remains open. diff --git a/USAGE_DATA.md b/USAGE_DATA.md index d9ac0d17..10c9d6ff 100644 --- a/USAGE_DATA.md +++ b/USAGE_DATA.md @@ -5,4 +5,6 @@ * when plugin is started * when plugin analyse dependency file for vulnerability(s) and file name * when plugin analyse dependency file fails, error message and file name +* when plugin runs batch workspace analysis on a workspace root +* when plugin batch workspace analysis fails, error message * when plugin is shutdown diff --git a/build.gradle.kts b/build.gradle.kts index 1cb1edc7..d3ecbc45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,8 @@ version = providers.gradleProperty("projectVersion").get() // Plugin version val platformVersion = providers.gradleProperty("ideaVersion").get() // Set the JVM language level used to build the project. java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 withSourcesJar() withJavadocJar() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9788b40..07a4627b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,10 +4,10 @@ caffeine = "3.1.8" commons-compress = "1.21" commons-io = "2.16.1" trustify-da-api-spec = "2.0.2" -trustify-da-java-client = "0.0.15" +trustify-da-java-client = "0.0.16" github-api = "1.314" junit = "4.13.2" -mockito = "4.11.0" +mockito = "5.14.2" packageurl-java = "1.4.1" # plugins diff --git a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java index 536c2fac..6b7dc3b4 100644 --- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java +++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java @@ -28,7 +28,11 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -87,6 +91,36 @@ public Path getStackAnalysis(final String packageManager, final String manifestN } } + /** + * Performs batch stack analysis on all workspace members in the given directory, + * returning the path to a temporary HTML report file. + * + * @param workspacePath the workspace root directory path + * @param ignorePatterns glob patterns for paths to exclude from workspace discovery + * @return the path to the generated HTML report file + */ + public Path getBatchStackAnalysis(final String workspacePath, final List ignorePatterns) { + var telemetryMsg = TelemetryService.instance().action("batch-stack-analysis"); + telemetryMsg.property(TelemetryKeys.PLATFORM.toString(), System.getProperty("os.name")); + telemetryMsg.property(TelemetryKeys.TRUST_DA_TOKEN.toString(), ApiSettingsState.getInstance().rhdaToken); + + try { + setBatchRequestProperties(); + Set ignorePatternsSet = new HashSet<>(ignorePatterns); + var htmlContent = exhortApi.stackAnalysisBatchHtml(Path.of(workspacePath), ignorePatternsSet); + var tmpFile = Files.createTempFile("exhort_batch_", ".html"); + Files.write(tmpFile, htmlContent.get()); + + telemetryMsg.send(); + return tmpFile; + + } catch (IOException | InterruptedException | ExecutionException exc) { + telemetryMsg.error(exc); + telemetryMsg.send(); + throw new RuntimeException(exc); + } + } + public ComponentAnalysisResult getComponentAnalysis(final String packageManager, final String manifestName, final String manifestPath) { var telemetryMsg = TelemetryService.instance().action("component-analysis"); telemetryMsg.property(TelemetryKeys.ECOSYSTEM.toString(), packageManager); @@ -117,7 +151,15 @@ public ComponentAnalysisResult getComponentAnalysis(final String packageManager, return null; } - private void setRequestProperties(final String manifestName) { + /** + * Sets up common request properties shared across all analysis types (stack, batch, image). + * Configures the plugin descriptor, backend connection, all tool paths, and proxy settings. + * Not all analysis types use every property configured here (e.g., batch analysis does not use + * Maven/Gradle/Python-specific properties, and image analysis does not use any of the package + * manager tool paths), but setting them unconditionally is harmless as unused properties are + * simply ignored by the backend. + */ + public static void setCommonRequestProperties() { String ideName = ApplicationInfo.getInstance().getFullApplicationName(); PluginDescriptor pluginDescriptor = PluginManagerCore.getPlugin(PluginId.getId("org.jboss.tools.intellij.analytics")); if (pluginDescriptor != null) { @@ -205,13 +247,6 @@ private void setRequestProperties(final String manifestName) { } else { System.clearProperty("TRUSTIFY_DA_CARGO_PATH"); } - if ("go.mod".equals(manifestName)) { - if (settings.goMatchManifestVersions) { - System.setProperty("MATCH_MANIFEST_VERSIONS", "true"); - } else { - System.clearProperty("MATCH_MANIFEST_VERSIONS"); - } - } if (settings.usePython2) { if (settings.pythonPath != null && !settings.pythonPath.isBlank()) { System.setProperty("TRUSTIFY_DA_PYTHON_PATH", settings.pythonPath); @@ -250,16 +285,6 @@ private void setRequestProperties(final String manifestName) { System.clearProperty("TRUSTIFY_DA_PYTHON_VIRTUAL_ENV"); System.clearProperty("TRUSTIFY_DA_PYTHON_INSTALL_BEST_EFFORTS"); } - if ("requirements.txt".equals(manifestName) || "pyproject.toml".equals(manifestName)) { - if (settings.pythonMatchManifestVersions) { - System.setProperty("MATCH_MANIFEST_VERSIONS", "true"); - } else { - System.setProperty("MATCH_MANIFEST_VERSIONS","false"); - } - } - if (!"go.mod".equals(manifestName) && !"requirements.txt".equals(manifestName) && !"pyproject.toml".equals(manifestName)) { - System.clearProperty("MATCH_MANIFEST_VERSIONS"); - } if (settings.licenseCheckEnabled) { System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "true"); } else { @@ -274,6 +299,41 @@ private void setRequestProperties(final String manifestName) { } } + private void setRequestProperties(final String manifestName) { + setCommonRequestProperties(); + + if ("go.mod".equals(manifestName)) { + ApiSettingsState settings = ApiSettingsState.getInstance(); + if (settings.goMatchManifestVersions) { + System.setProperty("MATCH_MANIFEST_VERSIONS", "true"); + } else { + System.clearProperty("MATCH_MANIFEST_VERSIONS"); + } + } else if ("requirements.txt".equals(manifestName)) { + ApiSettingsState settings = ApiSettingsState.getInstance(); + if (settings.pythonMatchManifestVersions) { + System.setProperty("MATCH_MANIFEST_VERSIONS", "true"); + } else { + System.setProperty("MATCH_MANIFEST_VERSIONS", "false"); + } + } else { + System.clearProperty("MATCH_MANIFEST_VERSIONS"); + } + } + + void setBatchRequestProperties() { + setCommonRequestProperties(); + + ApiSettingsState settings = ApiSettingsState.getInstance(); + if (settings.batchConcurrency != null && !settings.batchConcurrency.isBlank()) { + System.setProperty("TRUSTIFY_DA_BATCH_CONCURRENCY", settings.batchConcurrency); + } else { + System.setProperty("TRUSTIFY_DA_BATCH_CONCURRENCY", "10"); + } + System.setProperty("TRUSTIFY_DA_CONTINUE_ON_ERROR", String.valueOf(settings.batchContinueOnError)); + System.setProperty("TRUSTIFY_DA_BATCH_METADATA", String.valueOf(settings.batchMetadata)); + } + public static void setBackendUrl() { String backendUrl = System.getenv(TRUSTIFY_DA_BACKEND_URL_PROPERTY); if (backendUrl == null || backendUrl.isBlank()) { diff --git a/src/main/java/org/jboss/tools/intellij/image/ApiService.java b/src/main/java/org/jboss/tools/intellij/image/ApiService.java index c387fcf0..d27207a2 100644 --- a/src/main/java/org/jboss/tools/intellij/image/ApiService.java +++ b/src/main/java/org/jboss/tools/intellij/image/ApiService.java @@ -11,13 +11,9 @@ package org.jboss.tools.intellij.image; -import com.intellij.ide.plugins.PluginManagerCore; -import com.intellij.openapi.application.ApplicationInfo; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.components.Service; import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.extensions.PluginDescriptor; -import com.intellij.openapi.extensions.PluginId; import io.github.guacsec.trustifyda.Api; import io.github.guacsec.trustifyda.api.v5.AnalysisReport; import io.github.guacsec.trustifyda.image.ImageRef; @@ -29,7 +25,6 @@ import java.nio.file.Path; import java.util.Collection; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -37,8 +32,7 @@ import java.util.stream.Collectors; import static org.jboss.tools.intellij.exhort.ApiService.createExhortApiWithBackendUrl; -import static org.jboss.tools.intellij.exhort.ApiService.getProxyUrl; -import static org.jboss.tools.intellij.exhort.ApiService.setBackendUrl; +import static org.jboss.tools.intellij.exhort.ApiService.setCommonRequestProperties; @Service(Service.Level.APP) public final class ApiService { @@ -114,19 +108,9 @@ Path getImageAnalysisReport(final Set imageRefs) { } private void setServiceEnvironment() { - var ideName = ApplicationInfo.getInstance().getFullApplicationName(); - PluginDescriptor pluginDescriptor = PluginManagerCore.getPlugin(PluginId.getId("org.jboss.tools.intellij.analytics")); - if (pluginDescriptor != null) { - var pluginName = pluginDescriptor.getName() + " " + pluginDescriptor.getVersion(); - System.setProperty("TRUST_DA_SOURCE", ideName + " / " + pluginName); - } else { - System.setProperty("TRUST_DA_SOURCE", ideName); - } + setCommonRequestProperties(); var settings = ApiSettingsState.getInstance(); - System.setProperty("TRUST_DA_TOKEN", settings.rhdaToken); - - setBackendUrl(); if (settings.syftPath != null && !settings.syftPath.isBlank()) { System.setProperty("TRUSTIFY_DA_SYFT_PATH", settings.syftPath); @@ -169,12 +153,5 @@ private void setServiceEnvironment() { } else { System.clearProperty("TRUSTIFY_DA_IMAGE_PLATFORM"); } - - Optional proxyUrlOpt = getProxyUrl(); - if (proxyUrlOpt.isPresent()) { - System.setProperty("TRUSTIFY_DA_PROXY_URL", proxyUrlOpt.get()); - } else { - System.clearProperty("TRUSTIFY_DA_PROXY_URL"); - } } } diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java index 04060642..769a5098 100644 --- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java +++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java @@ -81,6 +81,12 @@ public class ApiSettingsComponent { private final static String manifestExclusionPatternsLabel = "Component Analysis > Exclusion Patterns" + "
Specifies glob patterns for manifest files to exclude from component analysis." + "
One pattern per line. Examples: **/node_modules/**/package.json, test/**/pom.xml"; + private final static String batchConcurrencyLabel = "Batch Analysis > Concurrency" + + "
Maximum number of concurrent analyses during batch workspace analysis."; + private final static String batchContinueOnErrorLabel = "Batch Analysis > Continue on Error" + + "
Continue analyzing remaining packages when one package analysis fails."; + private final static String batchMetadataLabel = "Batch Analysis > Include Metadata" + + "
Include additional metadata in batch analysis results."; private final static String reportFilePathLabel = "Reports > Save Directory: Path" + "
Specifies directory where stack analytics reports will be saved permanently." + "
Leave empty to use temporary files only."; @@ -115,6 +121,9 @@ public class ApiSettingsComponent { private final TextFieldWithBrowseButton dockerPathText; private final TextFieldWithBrowseButton podmanPathText; private final JBTextField imagePlatformText; + private final JBTextField batchConcurrencyText; + private final JBCheckBox batchContinueOnErrorCheck; + private final JBCheckBox batchMetadataCheck; private final JBTextArea manifestExclusionPatternsText; private final JBScrollPane manifestExclusionPatternsScrollPane; private final TextFieldWithBrowseButton reportFilePathText; @@ -255,6 +264,10 @@ public ApiSettingsComponent() { imagePlatformText = new JBTextField(); + batchConcurrencyText = new JBTextField(); + batchContinueOnErrorCheck = new JBCheckBox("Continue analyzing when a package fails"); + batchMetadataCheck = new JBCheckBox("Include metadata in batch results"); + licenseCheckEnabledCheck = new JBCheckBox("Enable license compatibility checking"); manifestExclusionPatternsText = new JBTextArea(); @@ -324,6 +337,13 @@ public ApiSettingsComponent() { .addLabeledComponent(new JBLabel(imagePlatformLabel), imagePlatformText, 1, true) .addSeparator(10) .addVerticalGap(10) + .addLabeledComponent(new JBLabel(batchConcurrencyLabel), batchConcurrencyText, 1, true) + .addVerticalGap(10) + .addLabeledComponent(new JBLabel(batchContinueOnErrorLabel), batchContinueOnErrorCheck, 1, true) + .addVerticalGap(10) + .addLabeledComponent(new JBLabel(batchMetadataLabel), batchMetadataCheck, 1, true) + .addSeparator(10) + .addVerticalGap(10) .addLabeledComponent(new JBLabel(licenseCheckEnabledLabel), licenseCheckEnabledCheck, 1, true) .addSeparator(10) .addVerticalGap(10) @@ -552,6 +572,31 @@ public void setCargoPathText(@NotNull String text) { cargoPathText.setText(text); } + @NotNull + public String getBatchConcurrencyText() { + return batchConcurrencyText.getText(); + } + + public void setBatchConcurrencyText(@NotNull String text) { + batchConcurrencyText.setText(text); + } + + public boolean getBatchContinueOnErrorCheck() { + return batchContinueOnErrorCheck.isSelected(); + } + + public void setBatchContinueOnErrorCheck(boolean selected) { + batchContinueOnErrorCheck.setSelected(selected); + } + + public boolean getBatchMetadataCheck() { + return batchMetadataCheck.isSelected(); + } + + public void setBatchMetadataCheck(boolean selected) { + batchMetadataCheck.setSelected(selected); + } + @NotNull public String getManifestExclusionPatternsText() { return manifestExclusionPatternsText.getText(); diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java index dce33ff0..817d8442 100644 --- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java +++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java @@ -70,6 +70,9 @@ public boolean isModified() { modified |= !settingsComponent.getImagePlatformText().equals(settings.imagePlatform); modified |= !settingsComponent.getGradlePathText().equals(settings.gradlePath); modified |= !Objects.equals(settingsComponent.getCargoPathText(), settings.cargoPath); + modified |= !Objects.equals(settingsComponent.getBatchConcurrencyText(), settings.batchConcurrency); + modified |= settingsComponent.getBatchContinueOnErrorCheck() != settings.batchContinueOnError; + modified |= settingsComponent.getBatchMetadataCheck() != settings.batchMetadata; modified |= !settingsComponent.getManifestExclusionPatternsText().equals(settings.manifestExclusionPatterns); modified |= !settingsComponent.getReportFilePathText().equals(settings.reportFilePath); modified |= settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled; @@ -105,6 +108,9 @@ public void apply() { settings.gradlePath = settingsComponent.getGradlePathText(); settings.reportFilePath = settingsComponent.getReportFilePathText(); settings.cargoPath = settingsComponent.getCargoPathText(); + settings.batchConcurrency = settingsComponent.getBatchConcurrencyText(); + settings.batchContinueOnError = settingsComponent.getBatchContinueOnErrorCheck(); + settings.batchMetadata = settingsComponent.getBatchMetadataCheck(); // Check if license check setting changed boolean licenseCheckChanged = settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled; @@ -167,6 +173,9 @@ public void reset() { settingsComponent.setImagePlatformText(settings.imagePlatform != null ? settings.imagePlatform : ""); settingsComponent.setGradlePathText(settings.gradlePath != null ? settings.gradlePath : ""); settingsComponent.setCargoPathText(settings.cargoPath != null ? settings.cargoPath : ""); + settingsComponent.setBatchConcurrencyText(settings.batchConcurrency != null ? settings.batchConcurrency : "10"); + settingsComponent.setBatchContinueOnErrorCheck(settings.batchContinueOnError); + settingsComponent.setBatchMetadataCheck(settings.batchMetadata); settingsComponent.setManifestExclusionPatternsText(settings.manifestExclusionPatterns != null ? settings.manifestExclusionPatterns : ""); settingsComponent.setReportFilePathText(settings.reportFilePath != null ? settings.reportFilePath : ""); settingsComponent.setLicenseCheckEnabledCheck(settings.licenseCheckEnabled); diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java index 2a2dafe6..c9bea111 100644 --- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java +++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java @@ -65,6 +65,10 @@ public final class ApiSettingsState implements PersistentStateComponent ignorePatterns = ManifestExclusionManager.getExclusionPatterns(); + + indicator.setText("Generating batch analysis report..."); + + Path reportPath = apiService.getBatchStackAnalysis(basePath, ignorePatterns); + String reportLink = reportPath.toUri().toString(); + + JsonObject manifestDetails = new JsonObject(); + manifestDetails.addProperty("showParent", false); + manifestDetails.addProperty("manifestName", "batch-workspace-analysis.html"); + manifestDetails.addProperty("manifestPath", basePath); + manifestDetails.addProperty("manifestFileParent", project.getName()); + manifestDetails.addProperty("report_link", reportLink); + manifestDetails.addProperty("manifestNameWithoutExtension", "batch-workspace-analysis"); + + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) { + return; + } + try { + AnalyticsReportUtils analyticsReportUtils = new AnalyticsReportUtils(); + analyticsReportUtils.openCustomEditor( + FileEditorManager.getInstance(project), manifestDetails, project); + } catch (AlreadyDisposedException e) { + // Project was disposed — ignore silently. + } catch (Exception e) { + logger.error(e); + NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification( + "Batch Workspace Analysis", + "Can't open report: " + e.getLocalizedMessage(), + NotificationType.ERROR) + .notify(project); + } + }); + } catch (RuntimeException ex) { + logger.error(ex); + ApplicationManager.getApplication().invokeLater(() -> + NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification( + "Batch Workspace Analysis", + "Batch analysis failed: " + ex.getLocalizedMessage(), + NotificationType.ERROR) + .notify(project)); + } + } + }); + } + + /** + * Checks whether the project root contains a supported workspace layout + * (JS/TS with a lock file, or Cargo with Cargo.lock). + */ + static boolean isSupportedWorkspace(Path rootDir) { + // Cargo workspace + if (Files.isRegularFile(rootDir.resolve("Cargo.toml")) + && Files.isRegularFile(rootDir.resolve("Cargo.lock"))) { + return true; + } + // JS/TS workspace + if (Files.isRegularFile(rootDir.resolve("package.json"))) { + return Files.isRegularFile(rootDir.resolve("pnpm-lock.yaml")) + || Files.isRegularFile(rootDir.resolve("yarn.lock")) + || Files.isRegularFile(rootDir.resolve("package-lock.json")); + } + return false; + } + + /** + * Enables the action only when a project is open. + * + * @param event the action event + */ + @Override + public void update(AnActionEvent event) { + event.getPresentation().setEnabledAndVisible(event.getProject() != null); + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 65a4278d..e73c1f9d 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -61,6 +61,9 @@
  • Right click on a manifest file in the Project window, and click Dependency Analytics Report.
  • +
  • For JS/TS monorepos or Cargo workspaces, right click in the Project window and click + Batch Workspace Analysis Report to analyze all packages at once. +
  • @@ -606,6 +609,17 @@ + + + + + + + + + telemetryServiceMock; + private MockedStatic apiSettingsStateMock; + + private static final List BATCH_PROPERTIES = Arrays.asList( + "TRUSTIFY_DA_BATCH_CONCURRENCY", + "TRUSTIFY_DA_CONTINUE_ON_ERROR", + "TRUSTIFY_DA_BATCH_METADATA" + ); + private final Map originalProperties = new HashMap<>(); + + @Before + public void setUp() { + mockApi = mock(Api.class); + apiService = spy(new ApiService(mockApi)); + doNothing().when(apiService).setBatchRequestProperties(); + + // Mock TelemetryService + TelemetryMessageBuilder mockBuilder = mock(TelemetryMessageBuilder.class); + TelemetryMessageBuilder.ActionMessage mockAction = mock(TelemetryMessageBuilder.ActionMessage.class); + when(mockBuilder.action(anyString())).thenReturn(mockAction); + when(mockAction.property(anyString(), anyString())).thenReturn(mockAction); + telemetryServiceMock = mockStatic(TelemetryService.class); + telemetryServiceMock.when(TelemetryService::instance).thenReturn(mockBuilder); + + // Mock ApiSettingsState + ApiSettingsState mockSettings = new ApiSettingsState(); + mockSettings.rhdaToken = "test-token"; + apiSettingsStateMock = mockStatic(ApiSettingsState.class); + apiSettingsStateMock.when(ApiSettingsState::getInstance).thenReturn(mockSettings); + + for (String key : BATCH_PROPERTIES) { + originalProperties.put(key, System.getProperty(key)); + } + } + + @After + public void tearDown() { + telemetryServiceMock.close(); + apiSettingsStateMock.close(); + for (String key : BATCH_PROPERTIES) { + String original = originalProperties.get(key); + if (original != null) { + System.setProperty(key, original); + } else { + System.clearProperty(key); + } + } + } + + /** Verifies that getBatchStackAnalysis delegates to exhortApi and returns a valid HTML file. */ + @Test + public void testGetBatchStackAnalysis_returnsHtmlReport() throws Exception { + // Given + byte[] htmlContent = "Batch Report".getBytes(); + when(mockApi.stackAnalysisBatchHtml(any(Path.class), any())) + .thenReturn(CompletableFuture.completedFuture(htmlContent)); + + // When + Path result = apiService.getBatchStackAnalysis("/workspace", Collections.emptyList()); + + // Then + assertNotNull("Report path should not be null", result); + assertTrue("Report file should exist", Files.exists(result)); + String content = Files.readString(result); + assertEquals("Batch Report", content); + assertTrue("Report filename should have .html extension", result.toString().endsWith(".html")); + + Files.deleteIfExists(result); + } + + /** Verifies that exclusion patterns are correctly passed to the API as a Set. */ + @Test + public void testGetBatchStackAnalysis_passesIgnorePatterns() throws Exception { + // Given + byte[] htmlContent = "".getBytes(); + when(mockApi.stackAnalysisBatchHtml(any(Path.class), any())) + .thenReturn(CompletableFuture.completedFuture(htmlContent)); + List patterns = Arrays.asList("**/node_modules/**", "**/dist/**"); + + // When + Path result = apiService.getBatchStackAnalysis("/workspace", patterns); + + // Then + Set expectedPatterns = new HashSet<>(Arrays.asList("**/node_modules/**", "**/dist/**")); + verify(mockApi).stackAnalysisBatchHtml(eq(Path.of("/workspace")), eq(expectedPatterns)); + + Files.deleteIfExists(result); + } + + /** Verifies that an empty ignore patterns list results in an empty set passed to the API. */ + @Test + public void testGetBatchStackAnalysis_emptyIgnorePatterns() throws Exception { + // Given + byte[] htmlContent = "".getBytes(); + when(mockApi.stackAnalysisBatchHtml(any(Path.class), any())) + .thenReturn(CompletableFuture.completedFuture(htmlContent)); + + // When + Path result = apiService.getBatchStackAnalysis("/workspace", Collections.emptyList()); + + // Then + verify(mockApi).stackAnalysisBatchHtml(eq(Path.of("/workspace")), eq(Collections.emptySet())); + + Files.deleteIfExists(result); + } + + /** Verifies that IOException from the API is wrapped in RuntimeException. */ + @Test(expected = RuntimeException.class) + public void testGetBatchStackAnalysis_wrapsIOException() throws Exception { + // Given + when(mockApi.stackAnalysisBatchHtml(any(Path.class), any())) + .thenThrow(new IOException("workspace not found")); + + // When + apiService.getBatchStackAnalysis("/nonexistent", Collections.emptyList()); + } +} diff --git a/src/test/java/org/jboss/tools/intellij/stackanalysis/SaBatchActionTest.java b/src/test/java/org/jboss/tools/intellij/stackanalysis/SaBatchActionTest.java new file mode 100644 index 00000000..b699e532 --- /dev/null +++ b/src/test/java/org/jboss/tools/intellij/stackanalysis/SaBatchActionTest.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ + +package org.jboss.tools.intellij.stackanalysis; + +import org.jboss.tools.intellij.settings.ApiSettingsState; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Tests for batch analysis settings persistence and default values + * in ApiSettingsState. + */ +public class SaBatchActionTest { + + /** Verifies that batch settings fields have correct default values. */ + @Test + public void testBatchSettingsDefaults() { + // Given a fresh ApiSettingsState + ApiSettingsState settings = new ApiSettingsState(); + + // Then defaults should be set correctly + assertEquals("Default batch concurrency should be 10", "10", settings.batchConcurrency); + assertTrue("Default continueOnError should be true", settings.batchContinueOnError); + assertTrue("Default batchMetadata should be true", settings.batchMetadata); + } + + /** Verifies that batch settings fields can be modified and retain their values. */ + @Test + public void testBatchSettingsPersistence() { + // Given + ApiSettingsState settings = new ApiSettingsState(); + + // When modifying settings + settings.batchConcurrency = "5"; + settings.batchContinueOnError = false; + settings.batchMetadata = false; + + // Then values should be retained + assertEquals("5", settings.batchConcurrency); + assertFalse(settings.batchContinueOnError); + assertFalse(settings.batchMetadata); + } + + /** Verifies that exclusion patterns from ManifestExclusionManager are properly parseable. */ + @Test + public void testExclusionPatternConversion() { + // Given patterns in the settings format (newline-separated) + String patterns = "**/node_modules/**\n**/dist/**\n# comment\n \n**/build/**"; + + // When splitting and filtering (same logic as ManifestExclusionManager.parsePatterns) + String[] lines = patterns.split("[\\n\\r]+"); + java.util.List parsed = new java.util.ArrayList<>(); + for (String line : lines) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + parsed.add(trimmed); + } + } + + // Then comments and blank lines should be filtered out + assertEquals(3, parsed.size()); + assertEquals("**/node_modules/**", parsed.get(0)); + assertEquals("**/dist/**", parsed.get(1)); + assertEquals("**/build/**", parsed.get(2)); + } +}