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));
+ }
+}