Skip to content

Commit b45b033

Browse files
a-orenclaude
andcommitted
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>
1 parent 69dd3fe commit b45b033

6 files changed

Lines changed: 219 additions & 2 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,13 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
396396
<br >Right-click on any manifest file and select **Exclude from Component Analysis** to quickly add an exclusion pattern for that specific file.
397397

398398

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

gradle/libs.versions.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
caffeine = "3.1.8"
44
commons-compress = "1.21"
55
commons-io = "2.16.1"
6-
trustify-da-api-spec = "2.0.2"
7-
trustify-da-java-client = "0.0.15"
6+
trustify-da-api-spec = "2.0.7"
7+
trustify-da-java-client = "0.0.17"
88
github-api = "1.314"
99
junit = "4.13.2"
1010
mockito = "4.11.0"

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,25 @@ public ComponentAnalysisResult getComponentAnalysis(final String packageManager,
117117
return null;
118118
}
119119

120+
public String generateSbom(final String manifestName, final String manifestPath) {
121+
var telemetryMsg = TelemetryService.instance().action("sbom-generation");
122+
telemetryMsg.property(TelemetryKeys.ECOSYSTEM.toString(), manifestName);
123+
telemetryMsg.property(TelemetryKeys.PLATFORM.toString(), System.getProperty("os.name"));
124+
telemetryMsg.property(TelemetryKeys.MANIFEST.toString(), manifestName);
125+
telemetryMsg.property(TelemetryKeys.TRUST_DA_TOKEN.toString(), ApiSettingsState.getInstance().rhdaToken);
126+
127+
try {
128+
setRequestProperties(manifestName);
129+
String sbomJson = exhortApi.generateSbom(manifestPath);
130+
telemetryMsg.send();
131+
return sbomJson;
132+
} catch (IOException exc) {
133+
telemetryMsg.error(exc);
134+
telemetryMsg.send();
135+
throw new RuntimeException(exc);
136+
}
137+
}
138+
120139
private void setRequestProperties(final String manifestName) {
121140
String ideName = ApplicationInfo.getInstance().getFullApplicationName();
122141
PluginDescriptor pluginDescriptor = PluginManagerCore.getPlugin(PluginId.getId("org.jboss.tools.intellij.analytics"));
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
import java.util.Arrays;
35+
import java.util.List;
36+
37+
import com.intellij.notification.NotificationGroupManager;
38+
import com.intellij.notification.NotificationType;
39+
import org.jboss.tools.intellij.exhort.ApiService;
40+
41+
public class GenerateSbomAction extends AnAction {
42+
private static final Logger logger = Logger.getInstance(GenerateSbomAction.class);
43+
44+
private static final List<String> supportedManifestFiles = Arrays.asList(
45+
"pom.xml",
46+
"package.json",
47+
"go.mod",
48+
"requirements.txt",
49+
"build.gradle",
50+
"build.gradle.kts",
51+
"Cargo.toml",
52+
"pyproject.toml"
53+
);
54+
55+
@Override
56+
public @NotNull ActionUpdateThread getActionUpdateThread() {
57+
return ActionUpdateThread.BGT;
58+
}
59+
60+
@Override
61+
public void actionPerformed(@NotNull AnActionEvent event) {
62+
Project project = event.getProject();
63+
if (project == null) {
64+
return;
65+
}
66+
67+
VirtualFile manifestFile = event.getData(PlatformDataKeys.VIRTUAL_FILE);
68+
if (manifestFile == null) {
69+
return;
70+
}
71+
72+
FileSaverDescriptor descriptor = new FileSaverDescriptor(
73+
"Save SBOM",
74+
"Choose location to save the CycloneDX SBOM",
75+
"json"
76+
);
77+
FileSaverDialog dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project);
78+
79+
VirtualFile baseDir = LocalFileSystem.getInstance().findFileByPath(
80+
manifestFile.getParent().getPath()
81+
);
82+
VirtualFileWrapper fileWrapper = dialog.save(baseDir, "bom.json");
83+
if (fileWrapper == null) {
84+
return;
85+
}
86+
87+
Path savePath = fileWrapper.getFile().toPath();
88+
89+
ApplicationManager.getApplication().executeOnPooledThread(() -> {
90+
try {
91+
ApiService apiService = ApplicationManager.getApplication().getService(ApiService.class);
92+
String sbomJson = apiService.generateSbom(
93+
manifestFile.getName(),
94+
manifestFile.getPath()
95+
);
96+
97+
Files.writeString(savePath, sbomJson, StandardCharsets.UTF_8);
98+
99+
ApplicationManager.getApplication().invokeLater(() ->
100+
NotificationGroupManager.getInstance()
101+
.getNotificationGroup("Red Hat Dependency Analytics")
102+
.createNotification(
103+
"SBOM saved to " + savePath,
104+
NotificationType.INFORMATION
105+
)
106+
.notify(project)
107+
);
108+
} catch (Exception ex) {
109+
logger.error(ex);
110+
ApplicationManager.getApplication().invokeLater(() ->
111+
Messages.showErrorDialog(
112+
project,
113+
"SBOM generation failed: " + ex.getLocalizedMessage(),
114+
"Error"
115+
)
116+
);
117+
}
118+
});
119+
}
120+
121+
@Override
122+
public void update(AnActionEvent event) {
123+
PsiFile psiFile = event.getData(CommonDataKeys.PSI_FILE);
124+
if (psiFile != null) {
125+
event.getPresentation().setEnabledAndVisible(
126+
supportedManifestFiles.contains(psiFile.getName())
127+
);
128+
} else {
129+
event.getPresentation().setEnabledAndVisible(false);
130+
}
131+
}
132+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,17 @@
606606
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
607607
</group>
608608

609+
<group id="generateSbom-group">
610+
<action id="generateSbom" text="Generate SBOM"
611+
class="org.jboss.tools.intellij.stackanalysis.GenerateSbomAction"
612+
icon="/images/report-icon.png"/>
613+
<separator/>
614+
615+
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
616+
<add-to-group group-id="NavBarToolBar" anchor="first"/>
617+
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
618+
</group>
619+
609620
<group id="imageAnalysis-group">
610621
<action id="imageAnalysis" text="Image Analytics Report"
611622
class="org.jboss.tools.intellij.image.ImageReportAction"
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);
39+
assertNotNull("ApiService should have generateSbom(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)