Skip to content

Commit 87ab401

Browse files
a-orenclaude
andauthored
feat: add Generate SBOM action to IntelliJ plugin (#258)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d540f85 commit 87ab401

8 files changed

Lines changed: 231 additions & 28 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,13 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
415415
<br >Exclusion patterns configured in **Manifest Exclusion Patterns** are applied during workspace discovery to skip
416416
specific packages from the batch analysis.
417417

418+
- **Generate SBOM**
419+
<br >You can generate a CycloneDX SBOM (Software Bill of Materials) from any supported manifest file.
420+
Right-click on a manifest file in the editor or **Project** window, and click **Generate SBOM**.
421+
A native Save File dialog will open with a default filename of `bom.json`. Choose a location to save the SBOM.
422+
The generated SBOM is in CycloneDX JSON format and contains the full dependency tree of your project.
423+
This operation is performed locally and does not send any data to the backend.
424+
418425
- **Red Hat Dependency Analytics report**
419426
<br >The Red Hat Dependency Analytics report is a temporary HTML file that exist if the **Red Hat Dependency Analytics
420427
Report** tab remains open.

src/main/java/org/jboss/tools/intellij/exhort/ApiService.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@ public ComponentAnalysisResult getComponentAnalysis(final String packageManager,
151151
return null;
152152
}
153153

154+
public String generateSbom(final String packageManager, final String manifestName, final String manifestPath) {
155+
var telemetryMsg = TelemetryService.instance().action("sbom-generation");
156+
telemetryMsg.property(TelemetryKeys.ECOSYSTEM.toString(), packageManager);
157+
telemetryMsg.property(TelemetryKeys.PLATFORM.toString(), System.getProperty("os.name"));
158+
telemetryMsg.property(TelemetryKeys.MANIFEST.toString(), manifestName);
159+
telemetryMsg.property(TelemetryKeys.TRUST_DA_TOKEN.toString(), ApiSettingsState.getInstance().rhdaToken);
160+
161+
try {
162+
setRequestProperties(manifestName);
163+
String sbomJson = exhortApi.generateSbom(manifestPath);
164+
telemetryMsg.send();
165+
return sbomJson;
166+
} catch (IOException exc) {
167+
telemetryMsg.error(exc);
168+
telemetryMsg.send();
169+
throw new RuntimeException(exc);
170+
}
171+
}
172+
154173
/**
155174
* Sets up common request properties shared across all analysis types (stack, batch, image).
156175
* Configures the plugin descriptor, backend connection, all tool paths, and proxy settings.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package org.jboss.tools.intellij.stackanalysis;
12+
13+
import com.intellij.openapi.actionSystem.ActionUpdateThread;
14+
import com.intellij.openapi.actionSystem.AnAction;
15+
import com.intellij.openapi.actionSystem.AnActionEvent;
16+
import com.intellij.openapi.actionSystem.CommonDataKeys;
17+
import com.intellij.openapi.actionSystem.PlatformDataKeys;
18+
import com.intellij.openapi.application.ApplicationManager;
19+
import com.intellij.openapi.diagnostic.Logger;
20+
import com.intellij.openapi.fileChooser.FileChooserFactory;
21+
import com.intellij.openapi.fileChooser.FileSaverDescriptor;
22+
import com.intellij.openapi.fileChooser.FileSaverDialog;
23+
import com.intellij.openapi.project.Project;
24+
import com.intellij.openapi.ui.Messages;
25+
import com.intellij.openapi.vfs.LocalFileSystem;
26+
import com.intellij.openapi.vfs.VirtualFile;
27+
import com.intellij.openapi.vfs.VirtualFileWrapper;
28+
import com.intellij.psi.PsiFile;
29+
import org.jetbrains.annotations.NotNull;
30+
31+
import java.nio.charset.StandardCharsets;
32+
import java.nio.file.Files;
33+
import java.nio.file.Path;
34+
35+
import com.intellij.notification.NotificationGroupManager;
36+
import com.intellij.notification.NotificationType;
37+
import org.jboss.tools.intellij.componentanalysis.CAAnnotator;
38+
import org.jboss.tools.intellij.exhort.ApiService;
39+
40+
public class GenerateSbomAction extends AnAction {
41+
private static final Logger logger = Logger.getInstance(GenerateSbomAction.class);
42+
43+
@Override
44+
public @NotNull ActionUpdateThread getActionUpdateThread() {
45+
return ActionUpdateThread.BGT;
46+
}
47+
48+
@Override
49+
public void actionPerformed(@NotNull AnActionEvent event) {
50+
Project project = event.getProject();
51+
if (project == null) {
52+
return;
53+
}
54+
55+
VirtualFile manifestFile = event.getData(PlatformDataKeys.VIRTUAL_FILE);
56+
if (manifestFile == null) {
57+
return;
58+
}
59+
60+
FileSaverDescriptor descriptor = new FileSaverDescriptor(
61+
"Save SBOM",
62+
"Choose location to save the CycloneDX SBOM",
63+
"json"
64+
);
65+
FileSaverDialog dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project);
66+
67+
VirtualFile baseDir = LocalFileSystem.getInstance().findFileByPath(
68+
manifestFile.getParent().getPath()
69+
);
70+
VirtualFileWrapper fileWrapper = dialog.save(baseDir, "bom.json");
71+
if (fileWrapper == null) {
72+
return;
73+
}
74+
75+
Path savePath = fileWrapper.getFile().toPath();
76+
77+
ApplicationManager.getApplication().executeOnPooledThread(() -> {
78+
try {
79+
ApiService apiService = ApplicationManager.getApplication().getService(ApiService.class);
80+
String sbomJson = apiService.generateSbom(
81+
CAAnnotator.getPackageManager(manifestFile.getName()),
82+
manifestFile.getName(),
83+
manifestFile.getPath()
84+
);
85+
86+
Files.writeString(savePath, sbomJson, StandardCharsets.UTF_8);
87+
88+
ApplicationManager.getApplication().invokeLater(() -> {
89+
if (project.isDisposed()) {
90+
return;
91+
}
92+
NotificationGroupManager.getInstance()
93+
.getNotificationGroup("Red Hat Dependency Analytics")
94+
.createNotification(
95+
"SBOM saved to " + savePath,
96+
NotificationType.INFORMATION
97+
)
98+
.notify(project);
99+
});
100+
} catch (Exception ex) {
101+
logger.error(ex);
102+
ApplicationManager.getApplication().invokeLater(() -> {
103+
if (project.isDisposed()) {
104+
return;
105+
}
106+
Messages.showErrorDialog(
107+
project,
108+
"SBOM generation failed: " + ex.getLocalizedMessage(),
109+
"Error"
110+
);
111+
});
112+
}
113+
});
114+
}
115+
116+
@Override
117+
public void update(AnActionEvent event) {
118+
PsiFile psiFile = event.getData(CommonDataKeys.PSI_FILE);
119+
if (psiFile != null) {
120+
event.getPresentation().setEnabledAndVisible(
121+
SaUtils.SUPPORTED_MANIFEST_FILES.contains(psiFile.getName())
122+
);
123+
} else {
124+
event.getPresentation().setEnabledAndVisible(false);
125+
}
126+
}
127+
}

src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,9 @@
2828
import org.jboss.tools.intellij.report.AnalyticsReportUtils;
2929
import org.jetbrains.annotations.NotNull;
3030

31-
import java.util.Arrays;
32-
import java.util.List;
33-
3431
public class SaAction extends AnAction {
3532
private static final Logger logger = Logger.getInstance(SaAction.class);
3633

37-
private static final List<String> supportedManifestFiles = Arrays.asList(
38-
"pom.xml",
39-
"package.json",
40-
"go.mod",
41-
"requirements.txt",
42-
"build.gradle",
43-
"build.gradle.kts",
44-
"Cargo.toml",
45-
"pyproject.toml"
46-
);
47-
4834
public SaAction() {
4935

5036
}
@@ -123,7 +109,7 @@ public void update(AnActionEvent event) {
123109
// Check if file where context menu is opened is type of supported extension.
124110
// If yes then show the action for SA in menu
125111
if (psiFile != null) {
126-
event.getPresentation().setEnabledAndVisible(supportedManifestFiles
112+
event.getPresentation().setEnabledAndVisible(SaUtils.SUPPORTED_MANIFEST_FILES
127113
.contains(psiFile.getName()));
128114
} else {
129115
event.getPresentation().setEnabledAndVisible(false);

src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,21 @@
1616
import com.intellij.openapi.vfs.VirtualFile;
1717
import org.jboss.tools.intellij.exhort.ApiService;
1818

19+
import java.util.List;
20+
1921
public class SaUtils {
2022

23+
public static final List<String> SUPPORTED_MANIFEST_FILES = List.of(
24+
"pom.xml",
25+
"package.json",
26+
"go.mod",
27+
"requirements.txt",
28+
"build.gradle",
29+
"build.gradle.kts",
30+
"Cargo.toml",
31+
"pyproject.toml"
32+
);
33+
2134
public JsonObject performSA(VirtualFile manifestFile) {
2235
// Get SA report for given manifest file.
2336
String reportLink;

src/main/resources/META-INF/plugin.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,18 @@
616616

617617
<separator/>
618618

619+
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
620+
<add-to-group group-id="NavBarToolBar" anchor="first"/>
621+
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
622+
</group>
623+
624+
<group id="generateSbom-group">
625+
<action id="generateSbom" text="Generate SBOM"
626+
class="org.jboss.tools.intellij.stackanalysis.GenerateSbomAction"
627+
icon="/images/report-icon.png"/>
628+
<separator/>
629+
630+
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
619631
<add-to-group group-id="NavBarToolBar" anchor="first"/>
620632
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
621633
</group>

src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -413,20 +413,11 @@ public void testPep621IgnoreComment() {
413413

414414
// ── SaAction / SaUtils recognition tests ────────────────────────────────────
415415

416-
/** Verifies that SaAction.supportedManifestFiles contains pyproject.toml. */
416+
/** Verifies that SUPPORTED_MANIFEST_FILES contains pyproject.toml. */
417417
@Test
418-
public void testSaActionRecognizesPyprojectToml() throws Exception {
419-
// Given the SaAction class
420-
Field field = org.jboss.tools.intellij.stackanalysis.SaAction.class
421-
.getDeclaredField("supportedManifestFiles");
422-
field.setAccessible(true);
423-
424-
// When reading the supported manifest files list
425-
@SuppressWarnings("unchecked")
426-
List<String> manifests = (List<String>) field.get(null);
427-
428-
// Then pyproject.toml should be included
429-
assertTrue("SaAction should recognize pyproject.toml", manifests.contains("pyproject.toml"));
418+
public void testSaActionRecognizesPyprojectToml() {
419+
assertTrue("SUPPORTED_MANIFEST_FILES should contain pyproject.toml",
420+
org.jboss.tools.intellij.stackanalysis.SaUtils.SUPPORTED_MANIFEST_FILES.contains("pyproject.toml"));
430421
}
431422

432423
/** Verifies that SaUtils.determinePackageManagerName maps pyproject.toml to python. */
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
12+
package org.jboss.tools.intellij.exhort;
13+
14+
import io.github.guacsec.trustifyda.Api;
15+
import org.junit.Test;
16+
17+
import java.lang.reflect.Method;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertNotNull;
21+
22+
/**
23+
* Tests for ApiService that don't require IntelliJ platform initialization.
24+
* The generateSbom method depends on TelemetryService and ApiSettingsState
25+
* which need the platform, so we verify the API contract at the interface level.
26+
*/
27+
public class ApiServiceTest {
28+
29+
@Test
30+
public void apiInterfaceHasGenerateSbomMethod() throws NoSuchMethodException {
31+
Method method = Api.class.getMethod("generateSbom", String.class);
32+
assertNotNull("Api interface should have generateSbom(String) method", method);
33+
assertEquals("generateSbom should return String", String.class, method.getReturnType());
34+
}
35+
36+
@Test
37+
public void apiServiceHasGenerateSbomMethod() throws NoSuchMethodException {
38+
Method method = ApiService.class.getMethod("generateSbom", String.class, String.class, String.class);
39+
assertNotNull("ApiService should have generateSbom(String, String, String) method", method);
40+
assertEquals("generateSbom should return String", String.class, method.getReturnType());
41+
}
42+
43+
@Test
44+
public void apiServiceAcceptsApiInConstructor() throws NoSuchMethodException {
45+
// Verify the package-private constructor used for testing exists
46+
ApiService.class.getDeclaredConstructor(Api.class);
47+
}
48+
}

0 commit comments

Comments
 (0)