diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/MyBatis.java b/server/sonar-db-dao/src/main/java/org/sonar/db/MyBatis.java index 4c182b744f3f..337bf8af149d 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/MyBatis.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/MyBatis.java @@ -33,6 +33,8 @@ import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder; import org.apache.ibatis.session.TransactionIsolationLevel; +import org.sonar.db.ai.CsAiRuleCatalogDto; +import org.sonar.db.ai.CsAiRulesCatalogMapper; import org.sonar.db.alm.pat.AlmPatMapper; import org.sonar.db.alm.setting.AlmSettingMapper; import org.sonar.db.alm.setting.ProjectAlmKeyAndProject; @@ -221,6 +223,7 @@ public void start() { confBuilder.loadAlias("AnticipatedTransition", AnticipatedTransitionDto.class); confBuilder.loadAlias("CeTaskCharacteristic", CeTaskCharacteristicDto.class); confBuilder.loadAlias("Component", ComponentDto.class); + confBuilder.loadAlias("CsAiRuleCatalog", CsAiRuleCatalogDto.class); confBuilder.loadAlias("Cve", CveDto.class); confBuilder.loadAlias("CveCwe", CveCweDto.class); confBuilder.loadAlias("DevOpsPermissionsMapping", DevOpsPermissionsMappingDto.class); @@ -303,6 +306,7 @@ public void start() { CeTaskMessageMapper.class, ComponentKeyUpdaterMapper.class, ComponentMapper.class, + CsAiRulesCatalogMapper.class, CveMapper.class, CveCweMapper.class, DefaultQProfileMapper.class, diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/ai/CsAiRuleCatalogDto.java b/server/sonar-db-dao/src/main/java/org/sonar/db/ai/CsAiRuleCatalogDto.java new file mode 100644 index 000000000000..89ca4e00b8ba --- /dev/null +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/ai/CsAiRuleCatalogDto.java @@ -0,0 +1,64 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.db.ai; + +public class CsAiRuleCatalogDto { + + private String ruleKey; + private String language; + private String description; + private String contextSeverity; + + public String getRuleKey() { + return ruleKey; + } + + public CsAiRuleCatalogDto setRuleKey(String ruleKey) { + this.ruleKey = ruleKey; + return this; + } + + public String getLanguage() { + return language; + } + + public CsAiRuleCatalogDto setLanguage(String language) { + this.language = language; + return this; + } + + public String getDescription() { + return description; + } + + public CsAiRuleCatalogDto setDescription(String description) { + this.description = description; + return this; + } + + public String getContextSeverity() { + return contextSeverity; + } + + public CsAiRuleCatalogDto setContextSeverity(String contextSeverity) { + this.contextSeverity = contextSeverity; + return this; + } +} diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/ai/CsAiRulesCatalogMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/ai/CsAiRulesCatalogMapper.java new file mode 100644 index 000000000000..8faf3cf5ae9e --- /dev/null +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/ai/CsAiRulesCatalogMapper.java @@ -0,0 +1,30 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +/* + * SonarQube + */ +package org.sonar.db.ai; + +import org.apache.ibatis.annotations.Param; + +public interface CsAiRulesCatalogMapper { + + CsAiRuleCatalogDto selectByRuleKey(@Param("language") String language, @Param("ruleKey") String ruleKey); +} diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/ai/package-info.java b/server/sonar-db-dao/src/main/java/org/sonar/db/ai/package-info.java new file mode 100644 index 000000000000..4a8efcf5d0db --- /dev/null +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/ai/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.db.ai; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/ai/CsAiRulesCatalogMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/ai/CsAiRulesCatalogMapper.xml new file mode 100644 index 000000000000..63723dd00009 --- /dev/null +++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/ai/CsAiRulesCatalogMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts index b631960c2f4e..5ea358a17efc 100644 --- a/server/sonar-web/src/main/js/api/issues.ts +++ b/server/sonar-web/src/main/js/api/issues.ts @@ -31,6 +31,16 @@ import { } from '../types/issues'; import { Dict, FacetValue, IssueChangelog, SnippetsByComponent, SourceLine } from '../types/types'; +export type IssueContextResponse = { + componentKey?: string; + contextSeverity?: 'LOW' | 'MEDIUM' | 'HIGH'; + issueKey: string; + line: number; + ruleDescription?: string; + ruleKey?: string; + snippet: string; +}; + export function searchIssues(query: RequestData): Promise { return getJSON('/api/issues/search', query).catch(error => { if (error?.status === 403 ) { @@ -170,3 +180,7 @@ export function getIssueFlowSnippets(issueKey: string): Promise { + return getJSON('/api/issues/context', data).catch(throwGlobalError); +} diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/IssueContextActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/IssueContextActionIT.java new file mode 100644 index 000000000000..5a23dc6b3754 --- /dev/null +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/IssueContextActionIT.java @@ -0,0 +1,520 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.ws; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.sonar.api.utils.System2; +import org.sonar.api.web.UserRole; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.component.ProjectData; +import org.sonar.db.issue.IssueDto; +import org.sonar.db.protobuf.DbFileSources; +import org.sonar.db.rule.RuleDto; +import org.sonar.db.source.FileSourceDto; +import org.sonar.server.component.TestComponentFinder; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.issue.IssueFinder; +import org.sonar.server.source.HtmlSourceDecorator; +import org.sonar.server.source.SourceService; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.TestResponse; +import org.sonar.server.ws.WsActionTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.db.component.ComponentTesting.newFileDto; + +class IssueContextActionIT { + + @RegisterExtension + private final DbTester db = DbTester.create(System2.INSTANCE); + @RegisterExtension + private final UserSessionRule userSession = UserSessionRule.standalone(); + + private final HtmlSourceDecorator htmlSourceDecorator = mock(HtmlSourceDecorator.class); + private final SourceService sourceService = new SourceService(db.getDbClient(), htmlSourceDecorator); + private final IssueFinder issueFinder = new IssueFinder(db.getDbClient(), userSession); + private final IssueContextAction underTest = new IssueContextAction(userSession, db.getDbClient(), + issueFinder, TestComponentFinder.from(db), sourceService); + private final WsActionTester tester = new WsActionTester(underTest); + + @BeforeEach + void setUp() { + when(htmlSourceDecorator.getDecoratedSourceAsHtml(anyString(), anyString(), anyString())) + .thenAnswer(invocation -> invocation.getArguments()[0].toString()); + } + + @Test + void return_issue_context_with_low_severity_default_range() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(50)); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S100")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(25).setRuleKey(rule.getKey().repository(), rule.getKey().rule())); + System.out.println(rule.getKey().repository() + " " + rule.getKey().rule()); + System.out.println(rule.getRepositoryKey() + " " + rule.getRuleKey()); + insertRuleCatalog("java:S100", "Test rule", "LOW"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("issueKey").getAsString()).isEqualTo(issue.getKey()); + assertThat(json.get("ruleKey").getAsString()).isEqualTo("java:S100"); + assertThat(json.get("line").getAsInt()).isEqualTo(25); + assertThat(json.get("ruleDescription").getAsString()).isEqualTo("Test rule"); + assertThat(json.get("contextSeverity").getAsString()).isEqualTo("LOW"); + String snippet = json.get("snippet").getAsString(); + assertThat(snippet.split("\n").length).isEqualTo(11); // 25-5 to 25+5 = 11 lines + assertThat(snippet).contains("line 20"); + assertThat(snippet).contains("line 30"); + } + + @Test + void return_issue_context_with_medium_severity_method_range() { + ProjectData projectData = db.components().insertPrivateProject(); + List sourceLines = List.of( + "public class TestClass {", + " private String field;", + "", + " public void testMethod(int param) {", + " int x = 10;", + " if (x > 5) {", + " System.out.println(\"test\");", + " }", + " }", + "", + " public void anotherMethod() {", + " return;", + " }", + "}" + ); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), sourceLines); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S200")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(7) + .setRuleKey(rule.getKey().repository(), rule.getKey().rule())); // Line inside testMethod + insertRuleCatalog("java:S200", "Medium severity rule", "MEDIUM"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("contextSeverity").getAsString()).isEqualTo("MEDIUM"); + String snippet = json.get("snippet").getAsString(); + // Should include the entire method from line 4 to 8 + assertThat(snippet).contains("public void testMethod"); + assertThat(snippet).contains("System.out.println"); + assertThat(snippet).doesNotContain("anotherMethod"); // Should not include other methods + } + + @Test + void return_issue_context_with_high_severity_class_range() { + ProjectData projectData = db.components().insertPrivateProject(); + List sourceLines = List.of( + "package com.test;", + "", + "public class TestClass {", + " private String field;", + "", + " public void testMethod(int param) {", + " int x = 10;", + " System.out.println(\"test\");", + " }", + "", + " public void anotherMethod() {", + " return;", + " }", + "}" + ); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), sourceLines); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S300")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(7).setRuleKey(rule.getKey().repository(), rule.getKey().rule())); + insertRuleCatalog("java:S300", "High severity rule", "HIGH"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("contextSeverity").getAsString()).isEqualTo("HIGH"); + String snippet = json.get("snippet").getAsString(); + // Should include the entire class + assertThat(snippet).contains("public class TestClass"); + assertThat(snippet).contains("testMethod"); + assertThat(snippet).contains("anotherMethod"); + } + + @Test + void use_rule_key_from_request_param_if_provided() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto issueRule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S100")); + RuleDto paramRule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S200")); + IssueDto issue = db.issues().insertIssue(issueRule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10).setRuleKey(issueRule.getRepositoryKey(), issueRule.getKey().rule())); + insertRuleCatalog("java:S200", "Rule from param", "LOW"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .setParam("rule", "java:S200") + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("ruleKey").getAsString()).isEqualTo("java:S200"); + assertThat(json.get("ruleDescription").getAsString()).isEqualTo("Rule from param"); + } + + @Test + void use_rule_key_from_issue_if_not_provided_in_request() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S100")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10).setRuleKey(rule.getRepositoryKey(), rule.getKey().rule())); + insertRuleCatalog("java:S100", "Rule from issue", "LOW"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("ruleKey").getAsString()).isEqualTo("java:S100"); + assertThat(json.get("ruleDescription").getAsString()).isEqualTo("Rule from issue"); + } + + @Test + void handle_issue_without_rule_key() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10).setRuleKey(null, null)); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.has("ruleKey")).isFalse(); + assertThat(json.has("ruleDescription")).isFalse(); + assertThat(json.has("contextSeverity")).isFalse(); + } + + @Test + void handle_issue_with_null_line_number() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(null)); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("line").getAsInt()).isEqualTo(1); // Default to 1 + String snippet = json.get("snippet").getAsString(); + assertThat(snippet.split("\n").length).isLessThanOrEqualTo(6); // 1-5 to 1+5, but file might be shorter + } + + @Test + void handle_issue_with_line_zero() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(0)); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("line").getAsInt()).isEqualTo(1); // Default to 1 + } + + @Test + void handle_rule_catalog_entry_not_found() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S999")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10).setRuleKey(rule.getRepositoryKey(), rule.getRuleKey())); + // No catalog entry for java:S999 + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("ruleKey").getAsString()).isEqualTo("java:S999"); + assertThat(json.has("ruleDescription")).isFalse(); + assertThat(json.has("contextSeverity")).isFalse(); + // Should use default LOW behavior (±5 lines) + String snippet = json.get("snippet").getAsString(); + assertThat(snippet.split("\n").length).isEqualTo(11); + } + + @Test + void handle_medium_severity_when_method_signature_not_found() { + ProjectData projectData = db.components().insertPrivateProject(); + List sourceLines = List.of( + "line 1", + "line 2", + "line 3", + "if (condition) {", // Control flow, not a method + " int x = 10;", + " System.out.println(x);", + "}" + ); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), sourceLines); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S200")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(5).setRuleKey(rule.getRepositoryKey(), rule.getRuleKey())); + insertRuleCatalog("java:S200", "Medium rule", "MEDIUM"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + // Should fall back to ±5 lines when method signature not found + String snippet = json.get("snippet").getAsString(); + assertThat(snippet.split("\n").length).isLessThanOrEqualTo(11); + } + + @Test + void handle_empty_file() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), List.of()); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(1)); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + String snippet = json.get("snippet").getAsString(); + assertThat(snippet).isEmpty(); + } + + @Test + void fail_when_user_not_logged_in() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10)); + + TestRequest request = tester.newRequest() + .setParam("issue", issue.getKey()); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Authentication is required"); + } + + @Test + void fail_when_user_lacks_codeviewer_permission() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10)); + userSession.logIn("user").addProjectPermission(UserRole.USER, projectData.getProjectDto()); + + TestRequest request = tester.newRequest() + .setParam("issue", issue.getKey()); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void fail_when_issue_not_found() { + ProjectData projectData = db.components().insertPrivateProject(); + setUserWithValidPermission(projectData); + + TestRequest request = tester.newRequest() + .setParam("issue", "non-existent-issue-key"); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Issue with key 'non-existent-issue-key' does not exist"); + } + + @Test + void fail_when_missing_issue_parameter() { + userSession.logIn(); + + TestRequest request = tester.newRequest(); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void handle_case_insensitive_severity() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(20)); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S200")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(10).setRuleKey(rule.getRepositoryKey(), rule.getRuleKey())); + insertRuleCatalog("java:S200", "Test rule", "medium"); // lowercase + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + assertThat(json.get("contextSeverity").getAsString()).isEqualTo("medium"); + // Should use method extraction for MEDIUM + } + + @Test + void handle_snippet_at_file_boundaries() { + ProjectData projectData = db.components().insertPrivateProject(); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), createSourceLines(5)); + RuleDto rule = db.rules().insertIssueRule(); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(2)); // Near beginning + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + String snippet = json.get("snippet").getAsString(); + // Should handle gracefully when trying to extract before line 1 + assertThat(snippet).isNotEmpty(); + } + + @Test + void handle_complex_nested_methods() { + ProjectData projectData = db.components().insertPrivateProject(); + List sourceLines = List.of( + "public class Outer {", + " public void outerMethod() {", + " if (true) {", + " innerMethod();", + " }", + " }", + "", + " private void innerMethod() {", + " int x = 5;", + " }", + "}" + ); + ComponentDto file = insertFileWithSource(projectData.getMainBranchComponent(), sourceLines); + RuleDto rule = db.rules().insertIssueRule(r -> r.setRuleKey("java:S200")); + IssueDto issue = db.issues().insertIssue(rule, projectData.getMainBranchComponent(), file, + i -> i.setLine(9).setRuleKey(rule.getRepositoryKey(), rule.getRuleKey())); // Inside innerMethod + insertRuleCatalog("java:S200", "Medium rule", "MEDIUM"); + setUserWithValidPermission(projectData); + + TestResponse response = tester.newRequest() + .setParam("issue", issue.getKey()) + .execute(); + + JsonObject json = JsonParser.parseString(response.getInput()).getAsJsonObject(); + String snippet = json.get("snippet").getAsString(); + // Should extract innerMethod, not outerMethod + assertThat(snippet).contains("private void innerMethod"); + assertThat(snippet).contains("int x = 5"); + } + + private List createSourceLines(int count) { + return java.util.stream.IntStream.rangeClosed(1, count) + .mapToObj(i -> "line " + i) + .toList(); + } + + private ComponentDto insertFileWithSource(ComponentDto project, List sourceLines) { + ComponentDto file = db.components().insertComponent(newFileDto(project, project.uuid())); + DbFileSources.Data.Builder sourceDataBuilder = DbFileSources.Data.newBuilder(); + for (int i = 0; i < sourceLines.size(); i++) { + sourceDataBuilder.addLinesBuilder() + .setLine(i + 1) + .setSource(sourceLines.get(i)) + .build(); + } + db.getDbClient().fileSourceDao().insert(db.getSession(), new FileSourceDto() + .setUuid(java.util.UUID.randomUUID().toString()) + .setProjectUuid(project.uuid()) + .setFileUuid(file.uuid()) + .setSourceData(sourceDataBuilder.build())); + db.commit(); + return file; + } + + private void insertRuleCatalog(String ruleKey, String description, String contextSeverity) { + try (DbSession session = db.getDbClient().openSession(false)) { + java.sql.PreparedStatement stmt = session.getConnection() + .prepareStatement( + "INSERT INTO cs_ai_rules_catalog (rule_key, description, context_severity) VALUES (?, ?, ?)"); + stmt.setString(1, ruleKey); + stmt.setString(2, description); + stmt.setString(3, contextSeverity); + stmt.executeUpdate(); + session.commit(); + stmt.close(); + } catch (java.sql.SQLException e) { + throw new RuntimeException(e); + } + } + + private void setUserWithValidPermission(ProjectData projectData) { + userSession.logIn("user") + .addProjectPermission(UserRole.USER, projectData.getProjectDto()) + .addProjectPermission(UserRole.CODEVIEWER, projectData.getProjectDto()); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueContextAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueContextAction.java new file mode 100644 index 000000000000..775f3cd76de3 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueContextAction.java @@ -0,0 +1,369 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.ws; + +import static org.sonarqube.ws.client.issue.IssuesWsParameters.ACTION_CONTEXT; +import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_COMPONENT_KEY; +import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_CONTEXT_SEVERITY; +import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_ISSUE; +import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_RULE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService.NewAction; +import org.sonar.api.server.ws.WebService.NewController; +import org.sonar.api.utils.text.JsonWriter; +import org.sonar.api.web.UserRole; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.protobuf.DbFileSources.Line; +import org.sonar.server.component.ComponentFinder; +import org.sonar.server.issue.IssueFinder; +import org.sonar.server.user.UserSession; +import org.sonar.server.source.SourceService; +import org.sonar.db.issue.IssueDto; +import org.sonar.db.ai.CsAiRuleCatalogDto; +import org.sonar.db.ai.CsAiRulesCatalogMapper; +import org.sonar.db.component.BranchDto; + +public class IssueContextAction implements IssuesWsAction { + + private final UserSession userSession; + private final DbClient dbClient; + private final IssueFinder issueFinder; + private final ComponentFinder componentFinder; + private final SourceService sourceService; + + public IssueContextAction(UserSession userSession, DbClient dbClient, IssueFinder issueFinder, + ComponentFinder componentFinder, SourceService sourceService) { + this.userSession = userSession; + this.dbClient = dbClient; + this.issueFinder = issueFinder; + this.componentFinder = componentFinder; + this.sourceService = sourceService; + } + + @Override + public void define(NewController controller) { + NewAction action = controller.createAction(ACTION_CONTEXT).setDescription( + "Returns violation metadata and a contextual code snippet.") + .setSince("10.9").setHandler(this); + + action.createParam(PARAM_ISSUE).setDescription("Issue key").setRequired(true) + .setExampleValue("AU-Tpxb--iU5OvuD2FLy"); + + action.createParam(PARAM_RULE).setDescription("Optional rule key. If not provided, taken from the issue") + .setRequired(false).setExampleValue("java:S100"); + + action.createParam(PARAM_COMPONENT_KEY) + .setDescription("Optional component key. If not provided, taken from the issue") + .setRequired(false).setExampleValue("abc/sample.cls"); + + action.createParam(PARAM_CONTEXT_SEVERITY) + .setDescription("Optional severity of context. If not provided, taken from the issue") + .setRequired(false).setExampleValue("MEDIUM"); + } + + @Override + public void handle(Request request, Response response) throws Exception { + userSession.checkLoggedIn(); + String issueKey = request.mandatoryParam(PARAM_ISSUE); + + try (DbSession dbSession = dbClient.openSession(false); JsonWriter json = response.newJsonWriter()) { + IssueDto issue = issueFinder.getByKey(dbSession, issueKey); + ComponentDto componentDto = componentFinder.getByKey(dbSession, issue.getComponentKey()); + userSession.checkComponentPermission(UserRole.CODEVIEWER, componentDto); + + RuleCatalogInfo catalogInfo = lookupRuleCatalog(dbSession, issue.getRuleRepo(), issue.getRule()); + int centerLine = getCenterLine(issue); + SnippetRange snippetRange = determineSnippetRange(dbSession, componentDto, centerLine, + catalogInfo.contextSeverity); + String snippet = extractSnippet(dbSession, componentDto, snippetRange); + String ruleKey = getRuleKey(request, issue); + + // Resolve branch name + String branchName = null; + BranchDto branchDto = dbClient.branchDao().selectByUuid(dbSession, issue.getProjectUuid()).orElse(null); + if (branchDto != null) { + branchName = branchDto.isMain() ? "main" : branchDto.getBranchKey(); + } + + writeResponse(json, issue, ruleKey, catalogInfo, snippet, branchName, snippetRange); + } + } + + private String getRuleKey(Request request, IssueDto issue) { + String ruleParam = request.param("rule"); + if (ruleParam != null) { + return ruleParam; + } + if (issue.getRuleRepo() != null && issue.getRule() != null) { + return issue.getRuleRepo() + ":" + issue.getRule(); + } + return null; + } + + private RuleCatalogInfo lookupRuleCatalog(DbSession dbSession, String language, String ruleKey) { + if (ruleKey == null) { + return new RuleCatalogInfo("", ""); + } + CsAiRulesCatalogMapper mapper = dbSession.getMapper(CsAiRulesCatalogMapper.class); + CsAiRuleCatalogDto dto = mapper.selectByRuleKey(language, ruleKey); + if (dto == null) { + return new RuleCatalogInfo("", ""); + } + return new RuleCatalogInfo(dto.getDescription(), dto.getContextSeverity()); + } + + private int getCenterLine(IssueDto issue) { + Integer line = issue.getLine(); + return line != null && line > 0 ? line : 1; + } + + private SnippetRange determineSnippetRange(DbSession dbSession, ComponentDto file, int centerLine, + String contextSeverity) { + if ("MEDIUM".equalsIgnoreCase(contextSeverity) || "HIGH".equalsIgnoreCase(contextSeverity)) { + List contents = loadFileContents(dbSession, file); + int[] range = "HIGH".equalsIgnoreCase(contextSeverity) + ? findEnclosingClassRange(centerLine, contents) + : findEnclosingMethodRange(centerLine, contents); + return new SnippetRange(Math.max(1, range[0]), Math.max(range[0], range[1])); + } + return new SnippetRange(Math.max(1, centerLine - 5), centerLine + 5); + } + + private List loadFileContents(DbSession dbSession, ComponentDto file) { + Optional> allLinesOpt = sourceService.getLines(dbSession, file.uuid(), 1, Integer.MAX_VALUE); + List contents = new ArrayList<>(); + if (allLinesOpt.isPresent()) { + for (Line l : allLinesOpt.get()) { + contents.add(l.getSource()); + } + } + return contents; + } + + private String extractSnippet(DbSession dbSession, ComponentDto file, SnippetRange range) { + Optional> optLines = sourceService.getLines(dbSession, file.uuid(), range.from, range.to); + StringBuilder snippetBuilder = new StringBuilder(); + int counter = 1; + if (optLines.isPresent()) { + boolean first = true; + for (Line l : optLines.get()) { + if (!first) { + snippetBuilder.append('\n'); + } + first = false; + snippetBuilder.append("line ").append(counter++).append(" - ").append(l.getSource()); + } + } + return snippetBuilder.toString(); + } + + private void writeResponse(JsonWriter json, IssueDto issueDto, String ruleKey, RuleCatalogInfo catalogInfo, + String snippet, String branchName, SnippetRange snippetRange) { + // Derive projectKey and sourceFolder from componentKey + DerivedComponentInfo componentInfo = deriveFromComponentKey(issueDto.getComponentKey()); + int centerLine = getCenterLine(issueDto); + + json.beginObject(); + json.prop("repo_full_name", issueDto.getComponentKey()); + json.prop("branch", branchName); + json.prop("ruleKey", ruleKey); + json.prop("issueKey", issueDto.getKey()); + json.prop("target_file", componentInfo.sourceFolder); + json.prop("violation_lines", centerLine - snippetRange.from + ""); + json.prop("guard_policy", "loose"); + json.prop("sourceSnippetStartLine", snippetRange.from); + json.prop("sourceSnippetEndLine", snippetRange.to); + json.prop("snippetViolationLine", (long) centerLine - snippetRange.from); + json.prop("projectKey", componentInfo.projectKey); + if (catalogInfo.description != null) { + json.prop("ruleDescription", catalogInfo.description); + } + if (catalogInfo.contextSeverity != null) { + json.prop("contextSeverity", catalogInfo.contextSeverity); + } + json.prop("automation_mode", false); + json.prop("codesnippet", snippet); + json.endObject(); + } + + private DerivedComponentInfo deriveFromComponentKey(String componentKey) { + if (componentKey == null) { + return new DerivedComponentInfo(null, null); + } + int sep = componentKey.indexOf(':'); + String proj = sep > 0 ? componentKey.substring(0, sep) : ""; + String path = sep > 0 && sep + 1 < componentKey.length() ? componentKey.substring(sep + 1) : null; + String folder = ""; + if (path != null) { + int slashIdx = path.lastIndexOf('/'); + if (slashIdx > 0) { + folder = path.substring(0, slashIdx); + } else { + folder = ""; + } + } + return new DerivedComponentInfo(proj, folder); + } + + private record DerivedComponentInfo(String projectKey, String sourceFolder) { + + } + + private record RuleCatalogInfo(String description, String contextSeverity) { + + } + + private record SnippetRange(int from, int to) { + + } + + private int[] findEnclosingMethodRange(int centerLine, List lines) { + int n = lines.size(); + int i = Math.max(1, centerLine); + // 1) find a likely method signature line above the center line + int signatureLine = -1; + for (int l = i; l >= 1; l--) { + String s = lines.get(l - 1).trim(); + if (isLikelyMethodSignature(s)) { + signatureLine = l; + break; + } + } + if (signatureLine == -1) { + // Fallback to a small context when we cannot be confident + int from = Math.max(1, i - 5); + int to = Math.min(n, i + 5); + return new int[]{from, to}; + } + + // 2) from the signature line, find the opening brace on this or subsequent lines (to handle signatures broken across lines) + int openBraceLine = findOpeningBraceAtOrBelow(signatureLine, lines); + if (openBraceLine == -1) { + // Likely a language without braces (e.g. Python) or unformatted source, fallback + int from = Math.max(1, signatureLine); + int to = Math.min(n, signatureLine + 20); + return new int[]{from, to}; + } + + // 3) match braces to find the end of the method block + int methodEnd = Math.max(openBraceLine, findMatchingBraceDownwards(openBraceLine, lines)); + return new int[]{openBraceLine, methodEnd}; + } + + private int[] findEnclosingClassRange(int centerLine, List lines) { + int i = Math.max(1, centerLine); + // Search upwards for a line hinting a class declaration + int classStartCandidate = i; + for (int l = i; l >= 1; l--) { + String s = lines.get(l - 1).trim(); + if (s.matches(".*\\bclass\\b.*\\{.*") || s.startsWith("class ") || s.matches(".*\\bclass\\b.*(:|$)")) { + classStartCandidate = l; + break; + } + } + int classStart = Math.max(1, findOpeningBraceAtOrAbove(classStartCandidate, lines)); + int classEnd = Math.max(classStart, findMatchingBraceDownwards(classStart, lines)); + return new int[]{classStart, classEnd}; + } + + private int findOpeningBraceAtOrAbove(int fromLine, List lines) { + for (int l = fromLine; l >= 1; l--) { + String s = lines.get(l - 1); + int idx = s.indexOf('{'); + if (idx >= 0) { + return l; + } + } + return Math.max(1, fromLine - 5); + } + + private int findOpeningBraceAtOrBelow(int fromLine, List lines) { + int n = lines.size(); + for (int l = fromLine; l <= n; l++) { + String s = lines.get(l - 1); + if (s.indexOf('{') >= 0) { + return l; + } + // stop early if we hit another declaration to avoid spanning too far + String t = s.trim(); + if (isLikelyMethodSignature(t) || t.contains(" class ")) { + break; + } + } + return -1; + } + + private int findMatchingBraceDownwards(int startLineWithBrace, List lines) { + int depth = 0; + boolean started = false; + int n = lines.size(); + for (int l = startLineWithBrace; l <= n; l++) { + String s = lines.get(l - 1); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + if (ch == '{') { + depth++; + started = true; + } else if (ch == '}') { + depth--; + if (started && depth == 0) { + return l; + } + } + } + } + return Math.min(n, startLineWithBrace + 50); + } + + private boolean isLikelyMethodSignature(String trimmed) { + if (trimmed.isEmpty()) { + return false; + } + // Exclude common control statements to avoid capturing blocks like if/for/while/switch/catch + String lower = trimmed.toLowerCase(); + if (lower.startsWith("if ") || lower.startsWith("if(") + || lower.startsWith("for ") || lower.startsWith("for(") + || lower.startsWith("while ") || lower.startsWith("while(") + || lower.startsWith("switch ") || lower.startsWith("switch(") + || lower.startsWith("catch ") || lower.startsWith("catch(") + || lower.startsWith("else") || lower.startsWith("do ") || lower.equals("do")) { + return false; + } + // Heuristic for method: contains '(' and ')' and not ending with ';' (to exclude declarations) and often followed by '{' + if (trimmed.contains("(") && trimmed.contains(")") && !trimmed.endsWith(";")) { + // Also ensure there's an identifier before '(' + int idx = trimmed.indexOf('('); + if (idx > 0) { + char prev = trimmed.charAt(idx - 1); + if (Character.isJavaIdentifierPart(prev)) { + return true; + } + } + } + return false; + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java index 2bae0e111806..658147f4d051 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java @@ -88,7 +88,8 @@ protected void configureModule() { AnticipatedTransitionParser.class, AnticipatedTransitionHandler.class, AnticipatedTransitionsActionValidator.class, - AnticipatedTransitionsAction.class + AnticipatedTransitionsAction.class, + IssueContextAction.class ); } } diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/issue/IssuesWsParameters.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/issue/IssuesWsParameters.java index a181cc62b376..cf6cbef3b0cb 100644 --- a/sonar-ws/src/main/java/org/sonarqube/ws/client/issue/IssuesWsParameters.java +++ b/sonar-ws/src/main/java/org/sonarqube/ws/client/issue/IssuesWsParameters.java @@ -42,6 +42,7 @@ public class IssuesWsParameters { public static final String ACTION_PULL = "pull"; public static final String ACTION_PULL_TAINT = "pull_taint"; public static final String ACTION_ANTICIPATED_TRANSITIONS = "anticipated_transitions"; + public static final String ACTION_CONTEXT = "context"; public static final String PARAM_ISSUE = "issue"; public static final String PARAM_IMPACT = "impact"; @@ -64,14 +65,17 @@ public class IssuesWsParameters { public static final String PARAM_RESOLVED = "resolved"; public static final String PARAM_PRIORITIZED_RULE = "prioritizedRule"; public static final String PARAM_COMPONENTS = "components"; + public static final String PARAM_COMPONENT_KEY = "componentKey"; public static final String PARAM_COMPONENT_KEYS = "componentKeys"; public static final String PARAM_COMPONENT_UUIDS = "componentUuids"; + public static final String PARAM_CONTEXT_SEVERITY = "contextSeverity"; public static final String PARAM_PROJECTS = "projects"; public static final String PARAM_DIRECTORIES = "directories"; public static final String PARAM_FILES = "files"; public static final String PARAM_ON_COMPONENT_ONLY = "onComponentOnly"; public static final String PARAM_BRANCH = "branch"; public static final String PARAM_PULL_REQUEST = "pullRequest"; + public static final String PARAM_RULE = "rule"; public static final String PARAM_RULES = "rules"; public static final String PARAM_ASSIGN = "assign"; public static final String PARAM_SET_SEVERITY = "set_severity";