From 1299ed1ed274097c49a734ed89c184269e24a1c0 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Tue, 28 Apr 2026 12:38:36 +0300 Subject: [PATCH 1/2] feat: add Generate SBOM action to IntelliJ plugin (#TC-3994) Add a "Generate SBOM" context menu action that generates a CycloneDX SBOM from supported manifest files and saves it to a user-chosen location via the native Save File dialog. Includes telemetry tracking and README docs. Co-Authored-By: Claude Opus 4.6 --- README.md | 7 + .../tools/intellij/exhort/ApiService.java | 19 +++ .../stackanalysis/GenerateSbomAction.java | 132 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 12 ++ .../tools/intellij/exhort/ApiServiceTest.java | 48 +++++++ 5 files changed, 218 insertions(+) create mode 100644 src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java create mode 100644 src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java diff --git a/README.md b/README.md index 4d6d7b0..059464a 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,13 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
Exclusion patterns configured in **Manifest Exclusion Patterns** are applied during workspace discovery to skip specific packages from the batch analysis. +- **Generate SBOM** +
You can generate a CycloneDX SBOM (Software Bill of Materials) from any supported manifest file. + Right-click on a manifest file in the editor or **Project** window, and click **Generate SBOM**. + A native Save File dialog will open with a default filename of `bom.json`. Choose a location to save the SBOM. + The generated SBOM is in CycloneDX JSON format and contains the full dependency tree of your project. + This operation is performed locally and does not send any data to the backend. + - **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/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java index 30f0921..a0e47f6 100644 --- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java +++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java @@ -151,6 +151,25 @@ public ComponentAnalysisResult getComponentAnalysis(final String packageManager, return null; } + public String generateSbom(final String packageManager, final String manifestName, final String manifestPath) { + var telemetryMsg = TelemetryService.instance().action("sbom-generation"); + telemetryMsg.property(TelemetryKeys.ECOSYSTEM.toString(), packageManager); + telemetryMsg.property(TelemetryKeys.PLATFORM.toString(), System.getProperty("os.name")); + telemetryMsg.property(TelemetryKeys.MANIFEST.toString(), manifestName); + telemetryMsg.property(TelemetryKeys.TRUST_DA_TOKEN.toString(), ApiSettingsState.getInstance().rhdaToken); + + try { + setRequestProperties(manifestName); + String sbomJson = exhortApi.generateSbom(manifestPath); + telemetryMsg.send(); + return sbomJson; + } catch (IOException exc) { + telemetryMsg.error(exc); + telemetryMsg.send(); + throw new RuntimeException(exc); + } + } + /** * 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. diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java new file mode 100644 index 0000000..c66e6a0 --- /dev/null +++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java @@ -0,0 +1,132 @@ +/******************************************************************************* + * Copyright (c) 2025 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 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.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileChooser.FileChooserFactory; +import com.intellij.openapi.fileChooser.FileSaverDescriptor; +import com.intellij.openapi.fileChooser.FileSaverDialog; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.vfs.VirtualFileWrapper; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import com.intellij.notification.NotificationGroupManager; +import com.intellij.notification.NotificationType; +import org.jboss.tools.intellij.exhort.ApiService; + +public class GenerateSbomAction extends AnAction { + private static final Logger logger = Logger.getInstance(GenerateSbomAction.class); + + private static final List supportedManifestFiles = Arrays.asList( + "pom.xml", + "package.json", + "go.mod", + "requirements.txt", + "build.gradle", + "build.gradle.kts", + "Cargo.toml", + "pyproject.toml" + ); + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + @Override + public void actionPerformed(@NotNull AnActionEvent event) { + Project project = event.getProject(); + if (project == null) { + return; + } + + VirtualFile manifestFile = event.getData(PlatformDataKeys.VIRTUAL_FILE); + if (manifestFile == null) { + return; + } + + FileSaverDescriptor descriptor = new FileSaverDescriptor( + "Save SBOM", + "Choose location to save the CycloneDX SBOM", + "json" + ); + FileSaverDialog dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project); + + VirtualFile baseDir = LocalFileSystem.getInstance().findFileByPath( + manifestFile.getParent().getPath() + ); + VirtualFileWrapper fileWrapper = dialog.save(baseDir, "bom.json"); + if (fileWrapper == null) { + return; + } + + Path savePath = fileWrapper.getFile().toPath(); + + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + ApiService apiService = ApplicationManager.getApplication().getService(ApiService.class); + String sbomJson = apiService.generateSbom( + manifestFile.getName(), + manifestFile.getPath() + ); + + Files.writeString(savePath, sbomJson, StandardCharsets.UTF_8); + + ApplicationManager.getApplication().invokeLater(() -> + NotificationGroupManager.getInstance() + .getNotificationGroup("Red Hat Dependency Analytics") + .createNotification( + "SBOM saved to " + savePath, + NotificationType.INFORMATION + ) + .notify(project) + ); + } catch (Exception ex) { + logger.error(ex); + ApplicationManager.getApplication().invokeLater(() -> + Messages.showErrorDialog( + project, + "SBOM generation failed: " + ex.getLocalizedMessage(), + "Error" + ) + ); + } + }); + } + + @Override + public void update(AnActionEvent event) { + PsiFile psiFile = event.getData(CommonDataKeys.PSI_FILE); + if (psiFile != null) { + event.getPresentation().setEnabledAndVisible( + supportedManifestFiles.contains(psiFile.getName()) + ); + } else { + event.getPresentation().setEnabledAndVisible(false); + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index e73c1f9..8df7069 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -616,6 +616,18 @@ + + + + + + + + + + diff --git a/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java b/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java new file mode 100644 index 0000000..0dddafa --- /dev/null +++ b/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java @@ -0,0 +1,48 @@ +/******************************************************************************* + * Copyright (c) 2025 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.exhort; + +import io.github.guacsec.trustifyda.Api; +import org.junit.Test; + +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Tests for ApiService that don't require IntelliJ platform initialization. + * The generateSbom method depends on TelemetryService and ApiSettingsState + * which need the platform, so we verify the API contract at the interface level. + */ +public class ApiServiceTest { + + @Test + public void apiInterfaceHasGenerateSbomMethod() throws NoSuchMethodException { + Method method = Api.class.getMethod("generateSbom", String.class); + assertNotNull("Api interface should have generateSbom(String) method", method); + assertEquals("generateSbom should return String", String.class, method.getReturnType()); + } + + @Test + public void apiServiceHasGenerateSbomMethod() throws NoSuchMethodException { + Method method = ApiService.class.getMethod("generateSbom", String.class, String.class); + assertNotNull("ApiService should have generateSbom(String, String) method", method); + assertEquals("generateSbom should return String", String.class, method.getReturnType()); + } + + @Test + public void apiServiceAcceptsApiInConstructor() throws NoSuchMethodException { + // Verify the package-private constructor used for testing exists + ApiService.class.getDeclaredConstructor(Api.class); + } +} From d05bf2aa44b6de88aabf498c76b29486cbe93c54 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Thu, 30 Apr 2026 12:12:12 +0300 Subject: [PATCH 2/2] fix: address PR review feedback for Generate SBOM action - Use packageManager instead of manifestName for ECOSYSTEM telemetry - Add project.isDisposed() checks in invokeLater blocks - Extract shared supportedManifestFiles list to SaUtils.SUPPORTED_MANIFEST_FILES Co-Authored-By: Claude Opus 4.6 --- .../stackanalysis/GenerateSbomAction.java | 55 +++++++++---------- .../intellij/stackanalysis/SaAction.java | 16 +----- .../tools/intellij/stackanalysis/SaUtils.java | 13 +++++ .../pypi/PyprojectCAAnnotatorTest.java | 17 ++---- .../tools/intellij/exhort/ApiServiceTest.java | 4 +- 5 files changed, 45 insertions(+), 60 deletions(-) diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java index c66e6a0..04b2c6d 100644 --- a/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java +++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java @@ -31,27 +31,15 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; import com.intellij.notification.NotificationGroupManager; import com.intellij.notification.NotificationType; +import org.jboss.tools.intellij.componentanalysis.CAAnnotator; import org.jboss.tools.intellij.exhort.ApiService; public class GenerateSbomAction extends AnAction { private static final Logger logger = Logger.getInstance(GenerateSbomAction.class); - private static final List supportedManifestFiles = Arrays.asList( - "pom.xml", - "package.json", - "go.mod", - "requirements.txt", - "build.gradle", - "build.gradle.kts", - "Cargo.toml", - "pyproject.toml" - ); - @Override public @NotNull ActionUpdateThread getActionUpdateThread() { return ActionUpdateThread.BGT; @@ -90,30 +78,37 @@ public void actionPerformed(@NotNull AnActionEvent event) { try { ApiService apiService = ApplicationManager.getApplication().getService(ApiService.class); String sbomJson = apiService.generateSbom( + CAAnnotator.getPackageManager(manifestFile.getName()), manifestFile.getName(), manifestFile.getPath() ); Files.writeString(savePath, sbomJson, StandardCharsets.UTF_8); - ApplicationManager.getApplication().invokeLater(() -> - NotificationGroupManager.getInstance() - .getNotificationGroup("Red Hat Dependency Analytics") - .createNotification( - "SBOM saved to " + savePath, - NotificationType.INFORMATION - ) - .notify(project) - ); + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) { + return; + } + NotificationGroupManager.getInstance() + .getNotificationGroup("Red Hat Dependency Analytics") + .createNotification( + "SBOM saved to " + savePath, + NotificationType.INFORMATION + ) + .notify(project); + }); } catch (Exception ex) { logger.error(ex); - ApplicationManager.getApplication().invokeLater(() -> - Messages.showErrorDialog( - project, - "SBOM generation failed: " + ex.getLocalizedMessage(), - "Error" - ) - ); + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) { + return; + } + Messages.showErrorDialog( + project, + "SBOM generation failed: " + ex.getLocalizedMessage(), + "Error" + ); + }); } }); } @@ -123,7 +118,7 @@ public void update(AnActionEvent event) { PsiFile psiFile = event.getData(CommonDataKeys.PSI_FILE); if (psiFile != null) { event.getPresentation().setEnabledAndVisible( - supportedManifestFiles.contains(psiFile.getName()) + SaUtils.SUPPORTED_MANIFEST_FILES.contains(psiFile.getName()) ); } else { event.getPresentation().setEnabledAndVisible(false); diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java index 481a7c8..c342938 100644 --- a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java +++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java @@ -28,23 +28,9 @@ import org.jboss.tools.intellij.report.AnalyticsReportUtils; import org.jetbrains.annotations.NotNull; -import java.util.Arrays; -import java.util.List; - public class SaAction extends AnAction { private static final Logger logger = Logger.getInstance(SaAction.class); - private static final List supportedManifestFiles = Arrays.asList( - "pom.xml", - "package.json", - "go.mod", - "requirements.txt", - "build.gradle", - "build.gradle.kts", - "Cargo.toml", - "pyproject.toml" - ); - public SaAction() { } @@ -123,7 +109,7 @@ public void update(AnActionEvent event) { // Check if file where context menu is opened is type of supported extension. // If yes then show the action for SA in menu if (psiFile != null) { - event.getPresentation().setEnabledAndVisible(supportedManifestFiles + event.getPresentation().setEnabledAndVisible(SaUtils.SUPPORTED_MANIFEST_FILES .contains(psiFile.getName())); } else { event.getPresentation().setEnabledAndVisible(false); diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java index 9bfcc44..40e5d6f 100644 --- a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java +++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java @@ -16,8 +16,21 @@ import com.intellij.openapi.vfs.VirtualFile; import org.jboss.tools.intellij.exhort.ApiService; +import java.util.List; + public class SaUtils { + public static final List SUPPORTED_MANIFEST_FILES = List.of( + "pom.xml", + "package.json", + "go.mod", + "requirements.txt", + "build.gradle", + "build.gradle.kts", + "Cargo.toml", + "pyproject.toml" + ); + public JsonObject performSA(VirtualFile manifestFile) { // Get SA report for given manifest file. String reportLink; diff --git a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java index ce1bdaa..aaa8b92 100644 --- a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java +++ b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java @@ -413,20 +413,11 @@ public void testPep621IgnoreComment() { // ── SaAction / SaUtils recognition tests ──────────────────────────────────── - /** Verifies that SaAction.supportedManifestFiles contains pyproject.toml. */ + /** Verifies that SUPPORTED_MANIFEST_FILES contains pyproject.toml. */ @Test - public void testSaActionRecognizesPyprojectToml() throws Exception { - // Given the SaAction class - Field field = org.jboss.tools.intellij.stackanalysis.SaAction.class - .getDeclaredField("supportedManifestFiles"); - field.setAccessible(true); - - // When reading the supported manifest files list - @SuppressWarnings("unchecked") - List manifests = (List) field.get(null); - - // Then pyproject.toml should be included - assertTrue("SaAction should recognize pyproject.toml", manifests.contains("pyproject.toml")); + public void testSaActionRecognizesPyprojectToml() { + assertTrue("SUPPORTED_MANIFEST_FILES should contain pyproject.toml", + org.jboss.tools.intellij.stackanalysis.SaUtils.SUPPORTED_MANIFEST_FILES.contains("pyproject.toml")); } /** Verifies that SaUtils.determinePackageManagerName maps pyproject.toml to python. */ diff --git a/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java b/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java index 0dddafa..fd2912a 100644 --- a/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java +++ b/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java @@ -35,8 +35,8 @@ public void apiInterfaceHasGenerateSbomMethod() throws NoSuchMethodException { @Test public void apiServiceHasGenerateSbomMethod() throws NoSuchMethodException { - Method method = ApiService.class.getMethod("generateSbom", String.class, String.class); - assertNotNull("ApiService should have generateSbom(String, String) method", method); + Method method = ApiService.class.getMethod("generateSbom", String.class, String.class, String.class); + assertNotNull("ApiService should have generateSbom(String, String, String) method", method); assertEquals("generateSbom should return String", String.class, method.getReturnType()); }