]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19372 add api/issues/anticipated_transitions endpoint
authorDimitris Kavvathas <dimitris.kavvathas@sonarsource.com>
Thu, 20 Jul 2023 13:41:51 +0000 (15:41 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 2 Aug 2023 20:03:03 +0000 (20:03 +0000)
19 files changed:
server/sonar-db-dao/src/it/java/org/sonar/db/issue/AnticipatedTransitionDaoIT.java
server/sonar-db-dao/src/main/java/org/sonar/db/issue/AnticipatedTransitionDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/issue/AnticipatedTransitionDto.java
server/sonar-db-dao/src/main/java/org/sonar/db/issue/AnticipatedTransitionMapper.java
server/sonar-db-dao/src/main/resources/org/sonar/db/issue/AnticipatedTransitionMapper.xml
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionIT.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionHandler.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionParser.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionValidator.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/GsonAnticipatedTransition.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionHandlerTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionParserTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionValidatorTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/resources/org/sonar/server/issue/ws/anticipatedtransition/request-with-transitions.json [new file with mode: 0644]
sonar-core/src/main/java/org/sonar/core/issue/AnticipatedTransition.java [new file with mode: 0644]
sonar-core/src/test/java/org/sonar/core/issue/AnticipatedTransitionTest.java [new file with mode: 0644]
sonar-ws/src/main/java/org/sonarqube/ws/client/issue/IssuesWsParameters.java

index 417455f9b6531fd712afcfa161cf4f65911532cb..a47126da22daa21d410133d92290e007662b9326 100644 (file)
@@ -1,3 +1,22 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.issue;
 
 import org.junit.Rule;
@@ -45,4 +64,44 @@ public class AnticipatedTransitionDaoIT {
     var anticipatedTransitionDtosDeleted = underTest.selectByProjectUuid(db.getSession(), projectUuid);
     assertThat(anticipatedTransitionDtosDeleted).isEmpty();
   }
+
+  @Test
+  public void deleteByProjectAndUser_shouldDeleteAllRelatedRecords() {
+    // given
+    final String projectUuid1 = "project1";
+    final String projectUuid2 = "project2";
+    String userUuid1 = "user1";
+    String userUuid2 = "user2";
+
+    generateAndInsertAnticipatedTransition("uuid1", projectUuid1, userUuid1); // should be deleted
+    generateAndInsertAnticipatedTransition("uuid2", projectUuid1, userUuid1); // should be deleted
+    generateAndInsertAnticipatedTransition("uuid3", projectUuid2, userUuid1);
+    generateAndInsertAnticipatedTransition("uuid4", projectUuid1, userUuid2);
+    generateAndInsertAnticipatedTransition("uuid5", projectUuid2, userUuid2);
+    generateAndInsertAnticipatedTransition("uuid6", projectUuid1, userUuid1); // should be deleted
+
+    // when
+    underTest.deleteByProjectAndUser(db.getSession(), projectUuid1, userUuid1);
+
+    // then
+    assertThat(underTest.selectByProjectUuid(db.getSession(), projectUuid1)).hasSize(1);
+    assertThat(underTest.selectByProjectUuid(db.getSession(), projectUuid2)).hasSize(2);
+  }
+
+  private void generateAndInsertAnticipatedTransition(String uuid, String projectUuid1, String userUuid1) {
+                   AnticipatedTransitionDto transition = new AnticipatedTransitionDto(
+      uuid,
+      projectUuid1,
+      userUuid1,
+      "transition",
+      "status",
+      "comment",
+      1,
+      "message",
+      "lineHash",
+      "ruleKey");
+
+    // insert one
+    underTest.insert(db.getSession(), transition);
+  }
 }
index 6deb8bea9854d5bad5f717a80ee6a4f32e8d6445..78c3b787c67f064ef3e80a86c49f92772b9ad8a0 100644 (file)
@@ -1,3 +1,22 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.issue;
 
 import java.util.List;
@@ -14,6 +33,10 @@ public class AnticipatedTransitionDao implements Dao {
     mapper(session).delete(uuid);
   }
 
+  public void deleteByProjectAndUser(DbSession dbSession, String projectUuid, String userUuid) {
+    mapper(dbSession).deleteByProjectAndUser(projectUuid, userUuid);
+  }
+
   public List<AnticipatedTransitionDto> selectByProjectUuid(DbSession session, String projectUuid) {
     return mapper(session).selectByProjectUuid(projectUuid);
   }
index 7611d8dd8bd5dc7e8f5c7822fed141d9dca5745a..7991e06401d6c5f40574e41da22f869357835322 100644 (file)
@@ -20,6 +20,7 @@
 package org.sonar.db.issue;
 
 import javax.annotation.Nullable;
+import org.sonar.core.issue.AnticipatedTransition;
 
 public class AnticipatedTransitionDto {
   private String uuid;
@@ -138,4 +139,18 @@ public class AnticipatedTransitionDto {
   public void setRuleKey(String ruleKey) {
     this.ruleKey = ruleKey;
   }
+
+  public static AnticipatedTransitionDto toDto(AnticipatedTransition anticipatedTransition, String uuid, String projectUuid) {
+    return new AnticipatedTransitionDto(
+      uuid,
+      projectUuid,
+      anticipatedTransition.getUserUuid(),
+      anticipatedTransition.getTransition(),
+      anticipatedTransition.getStatus(),
+      anticipatedTransition.getComment(),
+      anticipatedTransition.getLine(),
+      anticipatedTransition.getMessage(),
+      anticipatedTransition.getLineHash(),
+      anticipatedTransition.getRuleKey().toString());
+  }
 }
index dc8383557ee606f98fd50c6392761db6a531734a..7459f3165368eb2cbebf127f92bc953c72bad568 100644 (file)
@@ -27,5 +27,7 @@ public interface AnticipatedTransitionMapper {
 
   void delete(@Param("uuid") String uuid);
 
+  void deleteByProjectAndUser(@Param("projectUuid") String projectUuid, @Param("userUuid") String userUuid);
+
   List<AnticipatedTransitionDto> selectByProjectUuid(@Param("projectUuid") String projectUuid);
 }
index 3160d2d164acffb1897a879e9714c79f57bf893a..89a8c9e218183bfa5837910b9a9758aa2150201b 100644 (file)
     delete from anticipated_transitions where uuid=#{uuid}
   </delete>
 
+  <delete id="deleteByProjectAndUser" parameterType="string">
+    delete from anticipated_transitions where project_uuid=#{projectUuid,jdbcType=VARCHAR} and user_uuid=#{userUuid,jdbcType=VARCHAR}
+  </delete>
+
   <select id="selectByProjectUuid" parameterType="string" resultType="AnticipatedTransition">
     select
     <include refid="anticipatedTransitionsColumns"/>
diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionIT.java
new file mode 100644 (file)
index 0000000..b791331
--- /dev/null
@@ -0,0 +1,240 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.resources.ResourceTypeTree;
+import org.sonar.api.resources.ResourceTypes;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.core.component.DefaultResourceTypes;
+import org.sonar.core.util.SequenceUuidFactory;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.db.DbTester;
+import org.sonar.db.issue.AnticipatedTransitionDao;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.exceptions.ForbiddenException;
+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.assertj.core.api.Assertions.tuple;
+import static org.sonar.api.web.UserRole.CODEVIEWER;
+import static org.sonar.api.web.UserRole.ISSUE_ADMIN;
+import static org.sonar.db.component.ProjectTesting.newPrivateProjectDto;
+
+public class AnticipatedTransitionsActionIT {
+
+  private static final String PROJECT_UUID = "123";
+
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public DbTester db = DbTester.create();
+
+  private final ComponentFinder componentFinder = new ComponentFinder(db.getDbClient(), new ResourceTypes(new ResourceTypeTree[]{DefaultResourceTypes.get()}));
+  private final AnticipatedTransitionsActionValidator validator = new AnticipatedTransitionsActionValidator(db.getDbClient(), componentFinder, userSession);
+  private final UuidFactory uuidFactory = new SequenceUuidFactory();
+  private final AnticipatedTransitionDao anticipatedTransitionDao = db.getDbClient().anticipatedTransitionDao();
+  private final AnticipatedTransitionParser anticipatedTransitionParser = new AnticipatedTransitionParser();
+  private final AnticipatedTransitionHandler handler = new AnticipatedTransitionHandler(anticipatedTransitionParser, anticipatedTransitionDao, uuidFactory, db.getDbClient());
+  private final WsActionTester ws = new WsActionTester(new AnticipatedTransitionsAction(validator, handler));
+
+  @Test
+  public void definition() {
+    var definition = ws.getDef();
+    assertThat(definition.key()).isEqualTo("anticipated_transitions");
+    assertThat(definition.description()).isEqualTo("""
+      Receive a list of anticipated transitions that can be applied to not yet discovered issues on a specific project.<br>
+      Requires the following permission: 'Administer' on the specified project.<br><br>
+      Upon successful execution, the HTTP status code returned is 202 (Accepted).<br><br>
+      Request example:
+      <pre><code>
+      [
+        {
+          "ruleKey": "squid:S0001",
+          "issueMessage": "issueMessage1",
+          "filePath": "filePath1",
+          "line": 1,
+          "lineHash": "lineHash1",
+          "transition": "transition1",
+          "comment": "comment1"
+        },
+        {
+          "ruleKey": "squid:S0002",
+          "issueMessage": "issueMessage2",
+          "filePath": "filePath2",
+          "line": 2,
+          "lineHash": "lineHash2",
+          "transition": "transition2",
+          "comment": "comment2"
+        }
+      ]""");
+    assertThat(definition.isPost()).isTrue();
+    assertThat(definition.isInternal()).isTrue();
+    assertThat(definition.params()).extracting(WebService.Param::key, WebService.Param::isRequired, WebService.Param::description, WebService.Param::since).containsExactlyInAnyOrder(
+      tuple("projectKey", true, "The key of the project", "10.2")
+    );
+  }
+
+  @Test
+  public void givenRequestWithTransitions_whenHandle_thenAllTransitionsAreSaved() throws IOException {
+    // given
+    ProjectDto projectDto = mockProjectDto();
+    mockUser(projectDto, ISSUE_ADMIN);
+    String requestBody = readTestResourceFile("request-with-transitions.json");
+
+    // when
+    TestResponse response = getTestRequest(projectDto, requestBody).execute();
+
+    // then
+    assertThat(anticipatedTransitionDao.selectByProjectUuid(db.getSession(), projectDto.getUuid())).hasSize(2);
+    assertThat(response.getStatus()).isEqualTo(202);
+  }
+
+  @Test
+  public void givenRequestWithNoTransitions_whenHandle_thenNoTransitionsAreSaved() {
+    // given
+    ProjectDto projectDto = mockProjectDto();
+    mockUser(projectDto, ISSUE_ADMIN);
+    String requestBody = "[]";
+
+    // when
+    TestResponse response = getTestRequest(projectDto, requestBody).execute();
+
+    // then
+    assertThat(anticipatedTransitionDao.selectByProjectUuid(db.getSession(), projectDto.getUuid())).isEmpty();
+    assertThat(response.getStatus()).isEqualTo(202);
+  }
+
+  @Test
+  public void givenRequestWithInvalidBody_whenHandle_thenExceptionIsThrown() {
+    // given
+    ProjectDto projectDto = mockProjectDto();
+    mockUser(projectDto, ISSUE_ADMIN);
+    String requestBody = "invalidJson";
+
+    // when then
+    TestRequest request = getTestRequest(projectDto, requestBody);
+
+    assertThatThrownBy(request::execute)
+      .hasMessage("Unable to parse anticipated transitions from request body.")
+      .isInstanceOf(IllegalStateException.class);
+  }
+
+  @Test
+  public void givenTransitionsForUserAndProjectAlreadyExistInDb_whenHandle_thenTheNewTransitionsShouldReplaceTheOldOnes() throws IOException {
+    // given
+    ProjectDto projectDto = mockProjectDto();
+    mockUser(projectDto, ISSUE_ADMIN);
+    String requestBody = readTestResourceFile("request-with-transitions.json");
+    TestResponse response1 = getTestRequest(projectDto, requestBody).execute();
+    assertThat(anticipatedTransitionDao.selectByProjectUuid(db.getSession(), projectDto.getUuid())).hasSize(2);
+    assertThat(response1.getStatus()).isEqualTo(202);
+
+    // when
+    String requestBody2 = """
+      [
+        {
+          "ruleKey": "squid:S0003",
+          "issueMessage": "issueMessage3",
+          "filePath": "filePath3",
+          "line": 3,
+          "lineHash": "lineHash3",
+          "transition": "transition3",
+          "comment": "comment3"
+        }
+      ]""";
+    TestResponse response2 = getTestRequest(projectDto, requestBody2).execute();
+
+    // then
+    assertThat(anticipatedTransitionDao.selectByProjectUuid(db.getSession(), projectDto.getUuid())).hasSize(1);
+    assertThat(response2.getStatus()).isEqualTo(202);
+  }
+
+  @Test
+  public void givenRequestWithNoTransitions_whenHandle_thenExistingTransitionsForUserAndProjectShouldBePurged() throws IOException {
+    // given
+    ProjectDto projectDto = mockProjectDto();
+    mockUser(projectDto, ISSUE_ADMIN);
+    String requestBody = readTestResourceFile("request-with-transitions.json");
+    TestResponse response1 = getTestRequest(projectDto, requestBody).execute();
+    assertThat(anticipatedTransitionDao.selectByProjectUuid(db.getSession(), projectDto.getUuid())).hasSize(2);
+    assertThat(response1.getStatus()).isEqualTo(202);
+
+    // when
+    String requestBody2 = "[]";
+    TestResponse response2 = getTestRequest(projectDto, requestBody2).execute();
+
+    // then
+    assertThat(anticipatedTransitionDao.selectByProjectUuid(db.getSession(), projectDto.getUuid())).isEmpty();
+    assertThat(response2.getStatus()).isEqualTo(202);
+  }
+
+  @Test
+  public void givenUserWithoutAdminIssuesPermission_whenHandle_thenThrowException() throws IOException {
+    // given
+    ProjectDto projectDto = mockProjectDto();
+    mockUser(projectDto, CODEVIEWER);
+    String requestBody = readTestResourceFile("request-with-transitions.json");
+
+    // when
+    TestRequest request = getTestRequest(projectDto, requestBody);
+
+    // then
+    assertThatThrownBy(request::execute)
+      .hasMessage("Insufficient privileges")
+      .isInstanceOf(ForbiddenException.class);
+  }
+
+  private TestRequest getTestRequest(ProjectDto projectDto, String requestBody) {
+    return ws.newRequest()
+      .setParam("projectKey", projectDto.getKey())
+      .setMethod("POST")
+      .setMediaType("application/json")
+      .setPayload(requestBody);
+  }
+
+  private void mockUser(ProjectDto projectDto, String permission) {
+    UserDto user = db.users().insertUser();
+    db.users().insertProjectPermissionOnUser(user, permission, projectDto);
+    userSession.logIn(user);
+  }
+
+  private ProjectDto mockProjectDto() {
+    ProjectDto projectDto = newPrivateProjectDto(PROJECT_UUID);
+    db.getDbClient().projectDao().insert(db.getSession(), projectDto);
+    db.commit();
+    return projectDto;
+  }
+
+  private String readTestResourceFile(String fileName) throws IOException {
+    return Files.readString(Path.of(getClass().getResource(fileName).getPath()));
+  }
+
+}
index 4351b1edeca28b6967c847a700a21147c1b492cf..409896cbcf1d2fc75148e90c1c75fbb12fb2ef77 100644 (file)
@@ -32,6 +32,10 @@ import org.sonar.server.issue.WebIssueStorage;
 import org.sonar.server.issue.index.IssueQueryFactory;
 import org.sonar.server.issue.workflow.FunctionExecutor;
 import org.sonar.server.issue.workflow.IssueWorkflow;
+import org.sonar.server.issue.ws.anticipatedtransition.AnticipatedTransitionHandler;
+import org.sonar.server.issue.ws.anticipatedtransition.AnticipatedTransitionParser;
+import org.sonar.server.issue.ws.anticipatedtransition.AnticipatedTransitionsAction;
+import org.sonar.server.issue.ws.anticipatedtransition.AnticipatedTransitionsActionValidator;
 import org.sonar.server.issue.ws.pull.PullActionProtobufObjectGenerator;
 import org.sonar.server.issue.ws.pull.PullActionResponseWriter;
 import org.sonar.server.issue.ws.pull.PullTaintActionProtobufObjectGenerator;
@@ -80,6 +84,11 @@ public class IssueWsModule extends Module {
       PullTaintAction.class,
       PullActionResponseWriter.class,
       PullActionProtobufObjectGenerator.class,
-      PullTaintActionProtobufObjectGenerator.class);
+      PullTaintActionProtobufObjectGenerator.class,
+      AnticipatedTransitionParser.class,
+      AnticipatedTransitionHandler.class,
+      AnticipatedTransitionsActionValidator.class,
+      AnticipatedTransitionsAction.class
+    );
   }
 }
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionHandler.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionHandler.java
new file mode 100644 (file)
index 0000000..8607550
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.util.List;
+import org.sonar.core.issue.AnticipatedTransition;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.issue.AnticipatedTransitionDao;
+import org.sonar.db.issue.AnticipatedTransitionDto;
+import org.sonar.db.project.ProjectDto;
+
+public class AnticipatedTransitionHandler {
+
+  private final AnticipatedTransitionParser anticipatedTransitionParser;
+  private final AnticipatedTransitionDao anticipatedTransitionDao;
+  private final UuidFactory uuidFactory;
+  private final DbClient dbClient;
+
+
+  public AnticipatedTransitionHandler(AnticipatedTransitionParser anticipatedTransitionParser,
+    AnticipatedTransitionDao anticipatedTransitionDao, UuidFactory uuidFactory, DbClient dbClient) {
+    this.anticipatedTransitionParser = anticipatedTransitionParser;
+    this.anticipatedTransitionDao = anticipatedTransitionDao;
+    this.uuidFactory = uuidFactory;
+    this.dbClient = dbClient;
+  }
+
+  public void handleRequestBody(String requestBody, String userUuid, ProjectDto projectDto) {
+    // parse anticipated transitions from request body
+    List<AnticipatedTransition> anticipatedTransitions = anticipatedTransitionParser.parse(requestBody, userUuid, projectDto.getKey());
+
+    try (DbSession dbSession = dbClient.openSession(true)) {
+      // delete previous anticipated transitions for the user and project
+      deletePreviousAnticipatedTransitionsForUserAndProject(dbSession, userUuid, projectDto.getUuid());
+
+      // insert new anticipated transitions
+      insertAnticipatedTransitions(dbSession, anticipatedTransitions, projectDto.getUuid());
+      dbSession.commit();
+    }
+  }
+
+  private void deletePreviousAnticipatedTransitionsForUserAndProject(DbSession dbSession, String userUuid, String projectUuid) {
+    anticipatedTransitionDao.deleteByProjectAndUser(dbSession, projectUuid, userUuid);
+  }
+
+  private void insertAnticipatedTransitions(DbSession dbSession, List<AnticipatedTransition> anticipatedTransitions, String projectUuid) {
+    anticipatedTransitions.forEach(anticipatedTransition ->
+      anticipatedTransitionDao.insert(dbSession, AnticipatedTransitionDto.toDto(anticipatedTransition, uuidFactory.create(), projectUuid)));
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionParser.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionParser.java
new file mode 100644 (file)
index 0000000..2687b7b
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import com.google.gson.Gson;
+import java.util.Arrays;
+import java.util.List;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.core.issue.AnticipatedTransition;
+
+public class AnticipatedTransitionParser {
+  private static final Gson GSON = new Gson();
+
+  public List<AnticipatedTransition> parse(String requestBody, String userUuid, String projectKey) {
+    try {
+      List<GsonAnticipatedTransition> anticipatedTransitions = Arrays.asList(GSON.fromJson(requestBody, GsonAnticipatedTransition[].class));
+      return mapBodyToAnticipatedTransitions(anticipatedTransitions, userUuid, projectKey);
+    } catch (Exception e) {
+      throw new IllegalStateException("Unable to parse anticipated transitions from request body.", e);
+    }
+  }
+
+  private static List<AnticipatedTransition> mapBodyToAnticipatedTransitions(List<GsonAnticipatedTransition> anticipatedTransitions, String userUuid, String projectKey) {
+    return anticipatedTransitions.stream()
+      .map(anticipatedTransition -> new AnticipatedTransition(
+        projectKey,
+        "branch",
+        userUuid,
+        RuleKey.parse(anticipatedTransition.ruleKey()),
+        anticipatedTransition.message(),
+        anticipatedTransition.filePath(),
+        anticipatedTransition.line(),
+        anticipatedTransition.lineHash(),
+        anticipatedTransition.transition(),
+        anticipatedTransition.comment()))
+      .toList();
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsAction.java
new file mode 100644 (file)
index 0000000..ca89b13
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.io.BufferedReader;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.server.issue.ws.IssuesWsAction;
+import org.sonarqube.ws.client.issue.IssuesWsParameters;
+
+import static java.net.HttpURLConnection.HTTP_ACCEPTED;
+
+public class AnticipatedTransitionsAction implements IssuesWsAction {
+
+  private static final String PARAM_PROJECT_KEY = "projectKey";
+  private final AnticipatedTransitionsActionValidator validator;
+  private final AnticipatedTransitionHandler anticipatedTransitionHandler;
+
+  public AnticipatedTransitionsAction(AnticipatedTransitionsActionValidator validator, AnticipatedTransitionHandler anticipatedTransitionHandler) {
+    this.validator = validator;
+    this.anticipatedTransitionHandler = anticipatedTransitionHandler;
+  }
+
+
+  @Override
+  public void define(WebService.NewController controller) {
+    WebService.NewAction action = controller.createAction(IssuesWsParameters.ACTION_ANTICIPATED_TRANSITIONS).setDescription("""
+      Receive a list of anticipated transitions that can be applied to not yet discovered issues on a specific project.<br>
+      Requires the following permission: 'Administer' on the specified project.<br><br>
+      Upon successful execution, the HTTP status code returned is 202 (Accepted).<br><br>
+      Request example:
+      <pre><code>
+      [
+        {
+          "ruleKey": "squid:S0001",
+          "issueMessage": "issueMessage1",
+          "filePath": "filePath1",
+          "line": 1,
+          "lineHash": "lineHash1",
+          "transition": "transition1",
+          "comment": "comment1"
+        },
+        {
+          "ruleKey": "squid:S0002",
+          "issueMessage": "issueMessage2",
+          "filePath": "filePath2",
+          "line": 2,
+          "lineHash": "lineHash2",
+          "transition": "transition2",
+          "comment": "comment2"
+        }
+      ]""")
+      .setSince("10.2")
+      .setHandler(this)
+      .setInternal(true)
+      .setPost(true);
+
+    action.createParam(PARAM_PROJECT_KEY)
+      .setDescription("Project key")
+      .setRequired(true)
+      .setDescription("The key of the project")
+      .setExampleValue("my_project")
+      .setSince("10.2");
+
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    // validation
+    String userUuid = validator.validateUserLoggedIn();
+    ProjectDto projectDto = validator.validateProjectKey(request.mandatoryParam(PARAM_PROJECT_KEY));
+    validator.validateUserHasAdministerIssuesPermission(projectDto.getUuid());
+
+    String requestBody = getRequestBody(request);
+    anticipatedTransitionHandler.handleRequestBody(requestBody, userUuid, projectDto);
+
+    response.stream().setStatus(HTTP_ACCEPTED);
+  }
+
+  private static String getRequestBody(Request request) {
+    BufferedReader reader = request.getReader();
+    StringBuilder sb = new StringBuilder();
+    reader.lines().forEach(sb::append);
+    return sb.toString();
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionValidator.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionValidator.java
new file mode 100644 (file)
index 0000000..f7c6d0f
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.util.Objects;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.user.UserSession;
+
+import static org.sonar.api.web.UserRole.ISSUE_ADMIN;
+import static org.sonar.server.user.AbstractUserSession.insufficientPrivilegesException;
+
+public class AnticipatedTransitionsActionValidator {
+
+  private final DbClient dbClient;
+  private final ComponentFinder componentFinder;
+  private final UserSession userSession;
+
+
+  public AnticipatedTransitionsActionValidator(DbClient dbClient, ComponentFinder componentFinder, UserSession userSession) {
+    this.dbClient = dbClient;
+    this.componentFinder = componentFinder;
+    this.userSession = userSession;
+  }
+
+  public ProjectDto validateProjectKey(String projectKey) {
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      return componentFinder.getProjectByKey(dbSession, projectKey);
+    } catch (NotFoundException e) {
+      // To hide information about the existence or not of the project.
+      throw insufficientPrivilegesException();
+    }
+  }
+
+  public String validateUserLoggedIn() {
+    userSession.checkLoggedIn();
+    return userSession.getUuid();
+  }
+
+  public void validateUserHasAdministerIssuesPermission(String projectUuid) {
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      String userUuid = Objects.requireNonNull(userSession.getUuid());
+      if (!dbClient.authorizationDao().selectEntityPermissions(dbSession, projectUuid, userUuid).contains(ISSUE_ADMIN)){
+        throw insufficientPrivilegesException();
+      }
+    }
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/GsonAnticipatedTransition.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/anticipatedtransition/GsonAnticipatedTransition.java
new file mode 100644 (file)
index 0000000..6861b89
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import com.google.gson.annotations.SerializedName;
+
+public record GsonAnticipatedTransition(@SerializedName("ruleKey") String ruleKey, @SerializedName("issueMessage") String message,
+                                        @SerializedName("filePath") String filePath, @SerializedName("line") Integer line,
+                                        @SerializedName("lineHash") String lineHash, @SerializedName("transition") String transition,
+                                        @SerializedName("comment") String comment) {
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionHandlerTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionHandlerTest.java
new file mode 100644 (file)
index 0000000..6cc2bf6
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.util.List;
+import org.junit.Test;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.core.issue.AnticipatedTransition;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.issue.AnticipatedTransitionDao;
+import org.sonar.db.project.ProjectDto;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+public class AnticipatedTransitionHandlerTest {
+
+  private static final UuidFactory UUID_FACTORY = mock(UuidFactory.class);
+  public static final String USER_UUID = "userUuid";
+  public static final String PROJECT_KEY = "projectKey";
+  private final AnticipatedTransitionParser anticipatedTransitionParser = mock(AnticipatedTransitionParser.class);
+  private final AnticipatedTransitionDao anticipatedTransitionDao = mock(AnticipatedTransitionDao.class);
+  private final DbClient dbClient = mock(DbClient.class);
+  private final AnticipatedTransitionHandler underTest = new AnticipatedTransitionHandler(anticipatedTransitionParser, anticipatedTransitionDao, UUID_FACTORY, dbClient);
+
+  @Test
+  public void givenRequestBodyWithNoTransitions_whenHandleRequestBody_thenPreviousTransitionsArePurged() {
+    // given
+    ProjectDto projectDto = new ProjectDto()
+      .setKey(PROJECT_KEY);
+
+    String requestBody = "body_with_no_transitions";
+    doReturn(List.of())
+      .when(anticipatedTransitionParser).parse(requestBody, USER_UUID, PROJECT_KEY);
+
+    DbSession dbSession = mockDbSession();
+
+    // when
+    underTest.handleRequestBody(requestBody, USER_UUID, projectDto);
+
+    // then
+    verify(dbClient).openSession(true);
+    verify(anticipatedTransitionDao).deleteByProjectAndUser(dbSession, projectDto.getUuid(), USER_UUID);
+    verify(anticipatedTransitionDao, never()).insert(eq(dbSession), any());
+  }
+
+  @Test
+  public void fivenRequestBodyWithTransitions_whenHandleRequestBody_thenTransitionsAreInserted() {
+    // given
+    ProjectDto projectDto = new ProjectDto()
+      .setKey(PROJECT_KEY);
+
+    String requestBody = "body_with_transitions";
+    doReturn(List.of(populateAnticipatedTransition(), populateAnticipatedTransition()))
+      .when(anticipatedTransitionParser).parse(requestBody, USER_UUID, PROJECT_KEY);
+
+    DbSession dbSession = mockDbSession();
+
+    // when
+    underTest.handleRequestBody(requestBody, USER_UUID, projectDto);
+
+    // then
+    verify(dbClient).openSession(true);
+    verify(anticipatedTransitionDao).deleteByProjectAndUser(dbSession, projectDto.getUuid(), USER_UUID);
+    verify(anticipatedTransitionDao, times(2)).insert(eq(dbSession), any());
+  }
+
+  private DbSession mockDbSession() {
+    DbSession dbSession = mock(DbSession.class);
+    doReturn(dbSession).when(dbClient).openSession(true);
+    return dbSession;
+  }
+
+  private AnticipatedTransition populateAnticipatedTransition() {
+    return new AnticipatedTransition(
+      PROJECT_KEY,
+      "branch",
+      USER_UUID,
+      RuleKey.of("repo", "squid:S0001"),
+      "issueMessage1",
+      "filePath1",
+      1,
+      "lineHash1",
+      "transition1",
+      "comment1");
+  }
+}
\ No newline at end of file
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionParserTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionParserTest.java
new file mode 100644 (file)
index 0000000..6d648b5
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.assertj.core.api.Assertions;
+import org.junit.Test;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.core.issue.AnticipatedTransition;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AnticipatedTransitionParserTest {
+
+  private static final String USER_UUID = "userUuid";
+  private static final String PROJECT_KEY = "projectKey";
+  AnticipatedTransitionParser underTest = new AnticipatedTransitionParser();
+
+  @Test
+  public void givenRequestBodyWithMultipleTransition_whenParse_thenAllTransitionsAreReturned() throws IOException {
+    // given
+    String requestBody = readTestResourceFile("request-with-transitions.json");
+
+    // when
+    List<AnticipatedTransition> anticipatedTransitions = underTest.parse(requestBody, USER_UUID, PROJECT_KEY);
+
+    // then
+    // assert that all transitions are returned
+    assertThat(anticipatedTransitions)
+      .hasSize(2)
+      .containsExactlyElementsOf(transitionsExpectedFromTestFile());
+  }
+
+  @Test
+  public void givenRequestBodyWithNoTransitions_whenParse_ThenAnEmptyListIsReturned() {
+    // given
+    String requestBody = "[]";
+
+    // when
+    List<AnticipatedTransition> anticipatedTransitions = underTest.parse(requestBody, USER_UUID, PROJECT_KEY);
+
+    // then
+    assertThat(anticipatedTransitions).isEmpty();
+  }
+
+  @Test
+  public void givenRequestBodyWithInvalidJson_whenParse_thenExceptionIsThrown() {
+    // given
+    String requestBody = "invalidJson";
+
+    // when then
+    Assertions.assertThatThrownBy(() -> underTest.parse(requestBody, USER_UUID, PROJECT_KEY))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Unable to parse anticipated transitions from request body.");
+  }
+
+  // Handwritten Anticipated Transitions that are expected from the request-with-transitions.json file
+  private List<AnticipatedTransition> transitionsExpectedFromTestFile() {
+    return List.of(
+      new AnticipatedTransition(
+        PROJECT_KEY,
+        "branch",
+        USER_UUID,
+        RuleKey.parse("squid:S0001"),
+        "issueMessage1",
+        "filePath1",
+        1,
+        "lineHash1",
+        "transition1",
+        "comment1"),
+      new AnticipatedTransition(
+        PROJECT_KEY,
+        "branch",
+        USER_UUID,
+        RuleKey.parse("squid:S0002"),
+        "issueMessage2",
+        "filePath2",
+        2,
+        "lineHash2",
+        "transition2",
+        "comment2"));
+  }
+
+  private String readTestResourceFile(String fileName) throws IOException {
+    return Files.readString(Path.of(getClass().getResource(fileName).getPath()));
+  }
+
+}
\ No newline at end of file
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionValidatorTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/anticipatedtransition/AnticipatedTransitionsActionValidatorTest.java
new file mode 100644 (file)
index 0000000..2d92eb3
--- /dev/null
@@ -0,0 +1,137 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.anticipatedtransition;
+
+import java.util.Set;
+import org.junit.Test;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.permission.AuthorizationDao;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.user.UserSession;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.sonar.api.web.UserRole.ISSUE_ADMIN;
+
+public class AnticipatedTransitionsActionValidatorTest {
+
+  private final DbClient dbClient = mock(DbClient.class);
+  private final ComponentFinder componentFinder = mock(ComponentFinder.class);
+  private final UserSession userSession = mock(UserSession.class);
+
+  AnticipatedTransitionsActionValidator underTest = new AnticipatedTransitionsActionValidator(dbClient, componentFinder, userSession);
+
+  @Test
+  public void givenValidProjectKey_whenValidateProjectKey_thenReturnProjectDto() {
+    // given
+    String projectKey = "validProjectKey";
+    DbSession dbSession = mockDbSession();
+    ProjectDto projectDto = new ProjectDto();
+    doReturn(projectDto).when(componentFinder).getProjectByKey(dbSession, projectKey);
+
+    // when
+    ProjectDto returnedProjectDto = underTest.validateProjectKey(projectKey);
+
+    // then
+    assertThat(projectDto).isEqualTo(returnedProjectDto);
+  }
+
+  @Test
+  public void givenInvalidProjectKey_whenValidateProjectKey_thenThrowForbiddenException() {
+    // given
+    String projectKey = "invalidProjectKey";
+    DbSession dbSession = mockDbSession();
+    doThrow(NotFoundException.class).when(componentFinder).getProjectByKey(dbSession, projectKey);
+
+    // when then
+    assertThatThrownBy(() -> underTest.validateProjectKey(projectKey))
+      .withFailMessage("Insufficient privileges")
+      .isInstanceOf(ForbiddenException.class);
+
+  }
+
+  @Test
+  public void givenUserLoggedIn_whenValidateUserLoggedIn_thenReturnUserUuid() {
+    // given
+    String userUuid = "userUuid";
+    doReturn(userUuid).when(userSession).getUuid();
+
+    // when
+    String returnedUserUuid = underTest.validateUserLoggedIn();
+
+    // then
+    assertThat(returnedUserUuid).isEqualTo(userUuid);
+  }
+
+  @Test
+  public void givenUserHasAdministerIssuesPermission_whenValidateUserHasAdministerIssuesPermission_thenDoNothing() {
+    // given
+    String userUuid = "userUuid";
+    doReturn(userUuid).when(userSession).getUuid();
+    String projectUuid = "projectUuid";
+    DbSession dbSession = mockDbSession();
+    AuthorizationDao authorizationDao = mockAuthorizationDao();
+    doReturn(Set.of("permission1", ISSUE_ADMIN)).when(authorizationDao).selectEntityPermissions(dbSession, projectUuid, userUuid);
+
+    // when, then
+    assertThatCode(() -> underTest.validateUserHasAdministerIssuesPermission(projectUuid))
+      .doesNotThrowAnyException();
+    verify(dbClient, times(1)).authorizationDao();
+  }
+
+  @Test
+  public void givenUserDoesNotHaveAdministerIssuesPermission_whenValidateUserHasAdministerIssuesPermission_thenThrowForbiddenException() {
+    // given
+    String userUuid = "userUuid";
+    doReturn(userUuid).when(userSession).getUuid();
+    String projectUuid = "projectUuid";
+    DbSession dbSession = mockDbSession();
+    AuthorizationDao authorizationDao = mockAuthorizationDao();
+    doReturn(Set.of("permission1")).when(authorizationDao).selectEntityPermissions(dbSession, projectUuid, userUuid);
+
+    // when, then
+    assertThatThrownBy(() -> underTest.validateUserHasAdministerIssuesPermission(projectUuid))
+      .withFailMessage("Insufficient privileges")
+      .isInstanceOf(ForbiddenException.class);
+    verify(dbClient, times(1)).authorizationDao();
+  }
+
+  private AuthorizationDao mockAuthorizationDao() {
+    AuthorizationDao authorizationDao = mock(AuthorizationDao.class);
+    doReturn(authorizationDao).when(dbClient).authorizationDao();
+    return authorizationDao;
+  }
+
+  private DbSession mockDbSession() {
+    DbSession dbSession = mock(DbSession.class);
+    doReturn(dbSession).when(dbClient).openSession(false);
+    return dbSession;
+  }
+}
\ No newline at end of file
diff --git a/server/sonar-webserver-webapi/src/test/resources/org/sonar/server/issue/ws/anticipatedtransition/request-with-transitions.json b/server/sonar-webserver-webapi/src/test/resources/org/sonar/server/issue/ws/anticipatedtransition/request-with-transitions.json
new file mode 100644 (file)
index 0000000..c969723
--- /dev/null
@@ -0,0 +1,20 @@
+[
+  {
+    "ruleKey": "squid:S0001",
+    "issueMessage": "issueMessage1",
+    "filePath": "filePath1",
+    "line": 1,
+    "lineHash": "lineHash1",
+    "transition": "transition1",
+    "comment": "comment1"
+  },
+  {
+    "ruleKey": "squid:S0002",
+    "issueMessage": "issueMessage2",
+    "filePath": "filePath2",
+    "line": 2,
+    "lineHash": "lineHash2",
+    "transition": "transition2",
+    "comment": "comment2"
+  }
+]
\ No newline at end of file
diff --git a/sonar-core/src/main/java/org/sonar/core/issue/AnticipatedTransition.java b/sonar-core/src/main/java/org/sonar/core/issue/AnticipatedTransition.java
new file mode 100644 (file)
index 0000000..5d0fe39
--- /dev/null
@@ -0,0 +1,150 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.core.issue;
+
+import java.time.Instant;
+import java.util.Date;
+import java.util.Objects;
+import org.apache.commons.lang.builder.HashCodeBuilder;
+import org.jetbrains.annotations.Nullable;
+import org.sonar.api.issue.Issue;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.core.issue.tracking.Trackable;
+
+public class AnticipatedTransition implements Trackable {
+
+  private final String projectKey;
+  private final String branch;
+  private final String transition;
+  private final String userUuid;
+  private final String comment;
+  private final String filePath;
+  private final Integer line;
+  private final String message;
+  private final String lineHash;
+  private final RuleKey ruleKey;
+
+  public AnticipatedTransition(
+    String projectKey,
+    @Nullable String branch,
+    String userUuid,
+    @Nullable RuleKey ruleKey,
+    @Nullable String message,
+    @Nullable String filePath,
+    @Nullable Integer line,
+    @Nullable String lineHash,
+    String transition,
+    @Nullable String comment) {
+    this.projectKey = projectKey;
+    this.branch = branch;
+    this.transition = transition;
+    this.userUuid = userUuid;
+    this.comment = comment;
+    this.filePath = filePath;
+    this.line = line;
+    this.message = message;
+    this.lineHash = lineHash;
+    this.ruleKey = ruleKey;
+  }
+
+  public String getProjectKey() {
+    return projectKey;
+  }
+
+  public String getBranch() {
+    return branch;
+  }
+
+  public String getTransition() {
+    return transition;
+  }
+
+  public String getUserUuid() {
+    return userUuid;
+  }
+
+  public String getComment() {
+    return comment;
+  }
+
+  public String getFilePath() {
+    return filePath;
+  }
+
+  @Nullable
+  @Override
+  public Integer getLine() {
+    return line;
+  }
+
+  @Nullable
+  @Override
+  public String getMessage() {
+    return message;
+  }
+
+  @Nullable
+  @Override
+  public String getLineHash() {
+    return lineHash;
+  }
+
+  @Override
+  public RuleKey getRuleKey() {
+    return ruleKey;
+  }
+
+  @Override
+  public String getStatus() {
+    // Since it's an anticipated transition, the issue will always be open upon the first analysis.
+    return Issue.STATUS_OPEN;
+  }
+
+  @Override
+  public Date getUpdateDate() {
+    return Date.from(Instant.now());
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    AnticipatedTransition that = (AnticipatedTransition) o;
+    return Objects.equals(projectKey, that.projectKey)
+      && Objects.equals(branch, that.branch)
+      && Objects.equals(transition, that.transition)
+      && Objects.equals(userUuid, that.userUuid)
+      && Objects.equals(comment, that.comment)
+      && Objects.equals(filePath, that.filePath)
+      && Objects.equals(line, that.line)
+      && Objects.equals(message, that.message)
+      && Objects.equals(lineHash, that.lineHash)
+      && Objects.equals(ruleKey, that.ruleKey);
+  }
+
+  @Override
+  public int hashCode() {
+    return HashCodeBuilder.reflectionHashCode(this);
+  }
+}
diff --git a/sonar-core/src/test/java/org/sonar/core/issue/AnticipatedTransitionTest.java b/sonar-core/src/test/java/org/sonar/core/issue/AnticipatedTransitionTest.java
new file mode 100644 (file)
index 0000000..041753a
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.core.issue;
+
+import org.assertj.core.api.Assertions;
+import org.junit.Test;
+import org.sonar.api.rule.RuleKey;
+
+public class AnticipatedTransitionTest {
+
+  @Test
+  public void givenTwoAnticipatedTransitions_whenFieldsHaveTheSameValue_theyShouldBeEqual() {
+    AnticipatedTransition anticipatedTransition = getAnticipatedTransition();
+    AnticipatedTransition anticipatedTransition2 = getAnticipatedTransition();
+
+    assertFieldsAreTheSame(anticipatedTransition, anticipatedTransition2);
+    Assertions.assertThat(anticipatedTransition).isEqualTo(anticipatedTransition2);
+  }
+
+  @Test
+  public void givenTwoAnticipatedTransitions_whenFieldsHaveTheSameValue_hashcodeShouldBeTheSame() {
+    AnticipatedTransition anticipatedTransition = getAnticipatedTransition();
+    AnticipatedTransition anticipatedTransition2 = getAnticipatedTransition();
+
+    assertFieldsAreTheSame(anticipatedTransition, anticipatedTransition2);
+    Assertions.assertThat(anticipatedTransition).hasSameHashCodeAs(anticipatedTransition2);
+  }
+
+  private void assertFieldsAreTheSame(AnticipatedTransition anticipatedTransition, AnticipatedTransition anticipatedTransition2) {
+    Assertions.assertThat(anticipatedTransition.getProjectKey()).isEqualTo(anticipatedTransition2.getProjectKey());
+    Assertions.assertThat(anticipatedTransition.getBranch()).isEqualTo(anticipatedTransition2.getBranch());
+    Assertions.assertThat(anticipatedTransition.getUserUuid()).isEqualTo(anticipatedTransition2.getUserUuid());
+    Assertions.assertThat(anticipatedTransition.getTransition()).isEqualTo(anticipatedTransition2.getTransition());
+    Assertions.assertThat(anticipatedTransition.getComment()).isEqualTo(anticipatedTransition2.getComment());
+    Assertions.assertThat(anticipatedTransition.getFilePath()).isEqualTo(anticipatedTransition2.getFilePath());
+    Assertions.assertThat(anticipatedTransition.getLine()).isEqualTo(anticipatedTransition2.getLine());
+    Assertions.assertThat(anticipatedTransition.getMessage()).isEqualTo(anticipatedTransition2.getMessage());
+    Assertions.assertThat(anticipatedTransition.getLineHash()).isEqualTo(anticipatedTransition2.getLineHash());
+    Assertions.assertThat(anticipatedTransition.getRuleKey()).isEqualTo(anticipatedTransition2.getRuleKey());
+  }
+
+  private AnticipatedTransition getAnticipatedTransition() {
+    return new AnticipatedTransition(
+      "projectKey",
+      "branch",
+      "userUuid",
+      RuleKey.parse("rule:key"),
+      "message",
+      "filepath",
+      1,
+      "lineHash",
+      "transition",
+      "comment"
+    );
+  }
+
+}
index be7928515eefb32af7e7da1493b03cd5bedf5e2a..1284b1461b86bbdd614550831f2601c7437991c4 100644 (file)
@@ -41,6 +41,7 @@ public class IssuesWsParameters {
   public static final String ACTION_BULK_CHANGE = "bulk_change";
   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 PARAM_ISSUE = "issue";
   public static final String PARAM_COMMENT = "comment";