Skip to content

Commit eec3ef8

Browse files
committed
SLCORE-1755 SonarCodeContext CLI integration in SQ:IDE (dogfood only)
1 parent f50e4f3 commit eec3ef8

File tree

5 files changed

+461
-0
lines changed

5 files changed

+461
-0
lines changed

API_CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 10.35
2+
3+
## New features
4+
5+
* Add a new `CONTEXT_GENERATION` value in `org.sonarsource.sonarlint.core.rpc.protocol.backend.initialize.BackendCapability`. Clients using the feature need to declare it at initialization time. This is only accessible in dogfooding environments, and should be enabled in AI-related environments.
6+
17
# 10.33
28

39
## New features
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
* SonarLint Core - Implementation
3+
* Copyright (C) 2016-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonarsource.sonarlint.core;
21+
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.util.ArrayList;
25+
import java.util.HashSet;
26+
import java.util.List;
27+
import java.util.Optional;
28+
import java.util.Set;
29+
import javax.annotation.Nullable;
30+
import org.sonarsource.sonarlint.core.branch.SonarProjectBranchTrackingService;
31+
import org.sonarsource.sonarlint.core.commons.Binding;
32+
import org.sonarsource.sonarlint.core.commons.log.SonarLintLogger;
33+
import org.sonarsource.sonarlint.core.commons.monitoring.DogfoodEnvironmentDetectionService;
34+
import org.sonarsource.sonarlint.core.commons.util.git.ProcessWrapperFactory;
35+
import org.sonarsource.sonarlint.core.event.ConfigurationScopesAddedWithBindingEvent;
36+
import org.sonarsource.sonarlint.core.event.BindingConfigChangedEvent;
37+
import org.sonarsource.sonarlint.core.fs.ClientFileSystemService;
38+
import org.sonarsource.sonarlint.core.repository.config.ConfigurationRepository;
39+
import org.sonarsource.sonarlint.core.repository.connection.ConnectionConfigurationRepository;
40+
import org.sonarsource.sonarlint.core.rpc.protocol.SonarLintRpcClient;
41+
import org.sonarsource.sonarlint.core.rpc.protocol.backend.initialize.BackendCapability;
42+
import org.sonarsource.sonarlint.core.rpc.protocol.backend.initialize.InitializeParams;
43+
import org.sonarsource.sonarlint.core.rpc.protocol.client.connection.GetCredentialsParams;
44+
import org.springframework.context.event.EventListener;
45+
46+
/**
47+
* Dogfooding-only integration that runs SonarCodeContext CLI on repository open in connected mode.
48+
* Commands executed (in order): init, generate-md, install-sonar-mdc.
49+
* Outputs are expected under the '.sonar-code-context' directory.
50+
*/
51+
public class SonarCodeContextService {
52+
53+
private static final SonarLintLogger LOG = SonarLintLogger.get();
54+
private static final String SONAR_CODE_CONTEXT_DIR = ".sonar-code-context";
55+
private static final String CLI_EXECUTABLE = "sonar-code-context";
56+
57+
private final ClientFileSystemService clientFileSystemService;
58+
private final ConfigurationRepository configurationRepository;
59+
private final ConnectionConfigurationRepository connectionConfigurationRepository;
60+
private final SonarProjectBranchTrackingService branchTrackingService;
61+
private final SonarLintRpcClient client;
62+
private final ProcessWrapperFactory processWrapperFactory = new ProcessWrapperFactory();
63+
private final boolean isEnabled;
64+
65+
private final Set<String> initializedScopes = new HashSet<>();
66+
private final Set<String> mdcInstalledScopes = new HashSet<>();
67+
68+
public SonarCodeContextService(DogfoodEnvironmentDetectionService dogfoodEnvDetectionService,
69+
ClientFileSystemService clientFileSystemService,
70+
ConfigurationRepository configurationRepository,
71+
ConnectionConfigurationRepository connectionConfigurationRepository,
72+
SonarProjectBranchTrackingService branchTrackingService,
73+
SonarLintRpcClient client, InitializeParams params) {
74+
this.clientFileSystemService = clientFileSystemService;
75+
this.configurationRepository = configurationRepository;
76+
this.connectionConfigurationRepository = connectionConfigurationRepository;
77+
this.branchTrackingService = branchTrackingService;
78+
this.client = client;
79+
this.isEnabled = dogfoodEnvDetectionService.isDogfoodEnvironment()
80+
&& params.getBackendCapabilities().contains(BackendCapability.CONTEXT_GENERATION);
81+
}
82+
83+
@EventListener
84+
public void onConfigurationScopesAdded(ConfigurationScopesAddedWithBindingEvent event) {
85+
if (!isEnabled) {
86+
return;
87+
}
88+
89+
for (var configScopeId : event.getConfigScopeIds()) {
90+
var baseDir = clientFileSystemService.getBaseDir(configScopeId);
91+
// Only run for scopes that are directly bound (not inherited from parent)
92+
var bindingOpt = configurationRepository.getConfiguredBinding(configScopeId);
93+
if (baseDir != null && bindingOpt.isPresent()) {
94+
handleGeneration(configScopeId, baseDir, bindingOpt.get());
95+
} else {
96+
LOG.debug("No baseDir for configuration scope '{}' - skipping SonarCodeContext CLI", configScopeId);
97+
}
98+
}
99+
}
100+
101+
@EventListener
102+
public void onBindingChanged(BindingConfigChangedEvent event) {
103+
if (!isEnabled) {
104+
return;
105+
}
106+
107+
var configScopeId = event.configScopeId();
108+
var baseDir = clientFileSystemService.getBaseDir(configScopeId);
109+
var bindingOpt = configurationRepository.getConfiguredBinding(configScopeId);
110+
if (baseDir != null && bindingOpt.isPresent()) {
111+
handleGeneration(configScopeId, baseDir, bindingOpt.get());
112+
}
113+
}
114+
115+
private void handleGeneration(String configScopeId, Path baseDir, Binding binding) {
116+
var paramsOpt = prepareCliParams(binding, configScopeId);
117+
if (paramsOpt.isPresent()) {
118+
var workingDir = computeWorkingBaseDir(baseDir);
119+
if (initializedScopes.add(configScopeId)) {
120+
runInit(workingDir);
121+
}
122+
runGenerateGuidelines(workingDir, paramsOpt.get());
123+
runMergeMd(workingDir);
124+
if (mdcInstalledScopes.add(configScopeId)) {
125+
runInstallMdc(workingDir);
126+
}
127+
} else {
128+
LOG.debug("Missing parameters for SonarCodeContext CLI, skipping for configuration scope '{}'", configScopeId);
129+
}
130+
}
131+
132+
private Optional<CliParams> prepareCliParams(Binding binding, String configScopeId) {
133+
var connection = connectionConfigurationRepository.getConnectionById(binding.connectionId());
134+
if (connection == null) {
135+
return Optional.empty();
136+
}
137+
var url = connection.getUrl();
138+
var token = getTokenForConnection(binding.connectionId());
139+
if (token.isEmpty()) {
140+
return Optional.empty();
141+
}
142+
var branch = branchTrackingService.awaitEffectiveSonarProjectBranch(configScopeId).orElse(null);
143+
return Optional.of(new CliParams(url, token.get(), binding.sonarProjectKey(), branch));
144+
}
145+
146+
private Optional<String> getTokenForConnection(String connectionId) {
147+
try {
148+
var creds = client.getCredentials(new GetCredentialsParams(connectionId)).join().getCredentials();
149+
if (creds != null && creds.isLeft()) {
150+
var tokenDto = creds.getLeft();
151+
return Optional.ofNullable(tokenDto.getToken());
152+
}
153+
return Optional.empty();
154+
} catch (Exception e) {
155+
LOG.debug("Unable to retrieve token for connection '{}'", connectionId, e);
156+
return Optional.empty();
157+
}
158+
}
159+
160+
private void runInit(Path baseDir) {
161+
var command = new ArrayList<>(List.of(resolveCliExecutable(), "init"));
162+
execute(baseDir, command);
163+
var settings = baseDir.resolve(SONAR_CODE_CONTEXT_DIR).resolve("settings.json");
164+
if (Files.exists(settings)) {
165+
LOG.debug("Initialized SonarCodeContext settings at {}", settings);
166+
}
167+
}
168+
169+
private void runGenerateGuidelines(Path baseDir, CliParams params) {
170+
var command = new ArrayList<>(List.of(
171+
resolveCliExecutable(),
172+
"generate-md-guidelines",
173+
"--sq-url=" + params.sqUrl,
174+
"--sq-token=" + params.sqToken,
175+
"--sq-project-key=" + params.projectKey
176+
));
177+
execute(baseDir, command);
178+
}
179+
180+
private void runMergeMd(Path baseDir) {
181+
var command = new ArrayList<>(List.of(resolveCliExecutable(), "merge-md"));
182+
execute(baseDir, command);
183+
var merged = baseDir.resolve(SONAR_CODE_CONTEXT_DIR).resolve("SONAR.md");
184+
if (Files.exists(merged)) {
185+
LOG.debug("Merged SONAR.md at {}", merged);
186+
} else {
187+
LOG.debug("SONAR.md was not generated under {}", baseDir.resolve(SONAR_CODE_CONTEXT_DIR));
188+
}
189+
}
190+
191+
private void runInstallMdc(Path baseDir) {
192+
var command = new ArrayList<>(List.of(resolveCliExecutable(), "install-sonar-mdc", "--force"));
193+
execute(baseDir, command);
194+
var cursorRule = baseDir.resolve(".cursor").resolve("rules").resolve("sonar.mdc");
195+
if (Files.exists(cursorRule)) {
196+
LOG.debug("Generated sonar.mdc at {}", cursorRule);
197+
}
198+
}
199+
200+
private void execute(Path baseDir, List<String> command) {
201+
var result = processWrapperFactory.create(baseDir, LOG::debug, command.toArray(new String[0])).execute();
202+
if (result.exitCode() != 0) {
203+
LOG.debug("Command '{}' exited with code {} in {}", String.join(" ", command), result.exitCode(), baseDir);
204+
}
205+
}
206+
207+
private record CliParams(String sqUrl, String sqToken, String projectKey, @Nullable String sqBranch) {}
208+
209+
private static Path computeWorkingBaseDir(Path baseDir) {
210+
try {
211+
var current = baseDir;
212+
while (current != null) {
213+
if (Files.isDirectory(current.resolve(".git"))) {
214+
return current;
215+
}
216+
current = current.getParent();
217+
}
218+
} catch (Exception e) {
219+
// ignore and fallback
220+
}
221+
return baseDir;
222+
}
223+
224+
private static String resolveCliExecutable() {
225+
// Used for testing
226+
var prop = System.getProperty("sonar.code.context.executable");
227+
if (prop != null && !prop.isBlank()) {
228+
return prop;
229+
}
230+
return CLI_EXECUTABLE;
231+
}
232+
233+
}

backend/core/src/main/java/org/sonarsource/sonarlint/core/spring/SonarLintSpringAppConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.sonarsource.sonarlint.core.OrganizationsCache;
3838
import org.sonarsource.sonarlint.core.SharedConnectedModeSettingsProvider;
3939
import org.sonarsource.sonarlint.core.SonarCloudActiveEnvironment;
40+
import org.sonarsource.sonarlint.core.SonarCodeContextService;
4041
import org.sonarsource.sonarlint.core.SonarProjectsCache;
4142
import org.sonarsource.sonarlint.core.SonarQubeClientManager;
4243
import org.sonarsource.sonarlint.core.TokenGeneratorHelper;
@@ -182,6 +183,7 @@
182183
IssueSynchronizationService.class,
183184
HotspotSynchronizationService.class,
184185
ClientFileSystemService.class,
186+
SonarCodeContextService.class,
185187
PathTranslationService.class,
186188
ServerFilePathsProvider.class,
187189
FileExclusionService.class,

0 commit comments

Comments
 (0)