]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16370 added new endpoint api/issues/pull
authorLukasz Jarocki <lukasz.jarocki@sonarsource.com>
Wed, 1 Jun 2022 05:02:20 +0000 (07:02 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 1 Jun 2022 20:03:01 +0000 (20:03 +0000)
16 files changed:
server/sonar-db-dao/src/main/java/org/sonar/db/issue/IssueDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/issue/IssueMapper.java
server/sonar-db-dao/src/main/java/org/sonar/db/issue/IssueQueryParams.java [new file with mode: 0644]
server/sonar-db-dao/src/main/resources/org/sonar/db/issue/IssueMapper.xml
server/sonar-db-dao/src/test/java/org/sonar/db/issue/IssueDaoTest.java
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/PullAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionIssuesRetriever.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionProtobufObjectGenerator.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionResponseWriter.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/IssueWsModuleTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/PullActionTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/pull/PullActionIssuesRetrieverTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/pull/PullActionResponseWriterTest.java [new file with mode: 0644]
sonar-ws/src/main/java/org/sonarqube/ws/client/issue/IssuesWsParameters.java
sonar-ws/src/main/protobuf/ws-issues.proto

index 3bb2b4a76578589b92141d9270011c2cbf6cea91..fd64c2c90b6a4ef8511a5b18923abe908d95ee2c 100644 (file)
@@ -119,4 +119,12 @@ public class IssueDao implements Dao {
     return session.getMapper(IssueMapper.class);
   }
 
+  public List<IssueDto> selectByBranch(DbSession dbSession, IssueQueryParams issueQueryParams, int page) {
+    Pagination pagination = Pagination.forPage(page).andSize(DEFAULT_PAGE_SIZE);
+    return mapper(dbSession).selectByBranch(issueQueryParams, pagination);
+  }
+
+  public List<String> selectRecentlyClosedIssues(DbSession dbSession, IssueQueryParams issueQueryParams) {
+    return mapper(dbSession).selectRecentlyClosedIssues(issueQueryParams);
+  }
 }
index 1981d89a422f3958757ab50630869ca17d78b595..ba55992a0ab7aa00dac357f570a9210a81508478 100644 (file)
@@ -68,4 +68,9 @@ public interface IssueMapper {
     @Param("baseComponent") ComponentDto baseComponent,
     @Param("leakPeriodBeginningDate") long leakPeriodBeginningDate);
 
+
+  List<IssueDto> selectByBranch(@Param("queryParams") IssueQueryParams issueQueryParams,
+    @Param("pagination") Pagination pagination);
+
+  List<String> selectRecentlyClosedIssues(@Param("queryParams") IssueQueryParams issueQueryParams);
 }
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/issue/IssueQueryParams.java b/server/sonar-db-dao/src/main/java/org/sonar/db/issue/IssueQueryParams.java
new file mode 100644 (file)
index 0000000..bebf378
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+public class IssueQueryParams {
+
+  private final String projectUuid;
+  private final String branchName;
+  private final List<String> languages;
+  private final List<String> ruleRepositories;
+  private final boolean resolvedOnly;
+  private final Long changedSince;
+
+  public IssueQueryParams(String projectUuid, String branchName, @Nullable List<String> languages,
+    @Nullable List<String> ruleRepositories, boolean resolvedOnly, @Nullable Long changedSince) {
+    this.projectUuid = projectUuid;
+    this.branchName = branchName;
+    this.languages = languages;
+    this.ruleRepositories = ruleRepositories;
+    this.resolvedOnly = resolvedOnly;
+    this.changedSince = changedSince;
+  }
+
+  public String getProjectUuid() {
+    return projectUuid;
+  }
+
+  public String getBranchName() {
+    return branchName;
+  }
+
+  @CheckForNull
+  public List<String> getLanguages() {
+    return languages;
+  }
+
+  @CheckForNull
+  public List<String> getRuleRepositories() {
+    return ruleRepositories;
+  }
+
+  public boolean isResolvedOnly() {
+    return resolvedOnly;
+  }
+
+  @CheckForNull
+  public Long getChangedSince() {
+    return changedSince;
+  }
+}
index 5a64c24b5ea1e6e4a64f524014bde13e929afd63..f3a69e4aff0a3d280487d1fd9874efa6c9f43bda 100644 (file)
     left join users u on i.assignee = u.uuid
     left join new_code_reference_issues n on i.kee = n.issue_key
   </select>
+
+  <sql id="selectByBranchColumnsFinal">
+    result.kee as kee,
+    result.ruleUuid as ruleUuid,
+    result.createdAt as createdAt,
+    result.status as status,
+    result.ruleType as ruleType,
+    result.ruleRepo as ruleRepo,
+    result.ruleKey as ruleKey,
+    result.message as message,
+    result.severity as severity,
+    result.manualSeverity as manualSeverity,
+    result.type as type,
+    result.locations as locations,
+    result.component_uuid,
+    c.path as filePath
+  </sql>
+
+  <sql id="selectByBranchColumnsOuterQuery">
+    t.kee as kee,
+    t.ruleUuid as ruleUuid,
+    t.createdAt as createdAt,
+    t.status as status,
+    t.ruleType as ruleType,
+    t.ruleRepo as ruleRepo,
+    t.ruleKey as ruleKey,
+    t.message as message,
+    t.severity as severity,
+    t.manualSeverity as manualSeverity,
+    t.type as type,
+    t.locations as locations,
+    t.component_uuid
+  </sql>
+
+  <sql id="selectByBranchColumns">
+    i.kee as kee,
+    i.rule_uuid as ruleUuid,
+    i.created_at as createdAt,
+    i.status as status,
+    r.rule_type as ruleType,
+    r.plugin_name as ruleRepo,
+    r.plugin_rule_key as ruleKey,
+    i.message as message,
+    i.severity as severity,
+    i.manual_severity as manualSeverity,
+    i.issue_type as type,
+    i.locations as locations,
+    i.component_uuid as component_uuid
+  </sql>
+
+  <select id="selectByBranch" parameterType="map" resultType="Issue">
+    select
+      <include refid="selectByBranchColumns"/>
+    , p.path as filePath
+    from issues i
+        inner join project_branches b on i.project_uuid = b.project_uuid
+        inner join rules r on r.uuid = i.rule_uuid
+        inner join components p on p.uuid=i.component_uuid
+    where
+      b.kee = #{queryParams.branchName}
+      AND i.project_uuid = #{queryParams.projectUuid}
+     <if test="queryParams.changedSince != null">
+      AND i.issue_update_date &gt;= #{queryParams.changedSince,jdbcType=BIGINT}
+     </if>
+     <if test="queryParams.resolvedOnly == true">
+       AND i.status = 'RESOLVED'
+     </if>
+      AND i.status &lt;&gt; 'CLOSED'
+      AND i.issue_type &lt;&gt; 4
+     <if test="queryParams.ruleRepositories != null">
+      AND r.plugin_name IN
+        <foreach item="ruleRepository" index="index" collection="queryParams.ruleRepositories" open="(" separator="," close=")">
+          #{ruleRepository}
+        </foreach>
+    </if>
+    <if test="queryParams.languages != null">
+      AND r.language IN
+        <foreach item="language" index="index" collection="queryParams.languages" open="(" separator="," close=")">
+            #{language}
+        </foreach>
+    </if>
+    order by i.issue_creation_date ASC
+    limit #{pagination.pageSize,jdbcType=INTEGER} offset #{pagination.offset,jdbcType=INTEGER}
+  </select>
+
+  <select id="selectByBranch" parameterType="map" resultType="Issue" databaseId="mssql">
+    select
+      <include refid="selectByBranchColumnsFinal"/>
+    from
+    (select
+      <include refid="selectByBranchColumnsOuterQuery"/>
+    from (
+      select
+        row_number() over(order by i.issue_creation_date ASC) as row_number,
+          <include refid="selectByBranchColumns"/>
+      from issues i
+          inner join project_branches b on i.project_uuid = b.project_uuid
+          inner join rules r on r.uuid = i.rule_uuid
+      where
+        b.kee = #{queryParams.branchName}
+        AND i.project_uuid = #{queryParams.projectUuid}
+          <if test="queryParams.changedSince != null">
+        AND i.issue_update_date &gt;= #{queryParams.changedSince,jdbcType=BIGINT}
+         </if>
+         <if test="queryParams.resolvedOnly == true">
+           AND i.status = 'RESOLVED'
+         </if>
+        AND i.status &lt;&gt; 'CLOSED'
+          AND i.issue_type &lt;&gt; 4
+        <if test="queryParams.ruleRepositories != null">
+          AND r.plugin_name IN
+            <foreach item="ruleRepository" index="index" collection="queryParams.ruleRepositories" open="(" separator="," close=")">
+              #{ruleRepository}
+            </foreach>
+        </if>
+        <if test="queryParams.languages != null">
+          AND r.language IN
+            <foreach item="language" index="index" collection="queryParams.languages" open="(" separator="," close=")">
+                #{language}
+            </foreach>
+        </if>
+      order by row_number asc
+      offset #{pagination.offset} rows
+      fetch next #{pagination.pageSize,jdbcType=INTEGER} rows only
+    ) as t) as result
+    inner join components c on c.uuid=result.component_uuid
+  </select>
+
+  <select id="selectByBranch" parameterType="map" resultType="Issue" databaseId="oracle">
+    select <include refid="selectByBranchColumnsFinal"/> from
+      (select <include refid="selectByBranchColumnsOuterQuery"/> from (
+        select rownum as rn, a.* from (
+          select
+            <include refid="selectByBranchColumns"/>
+          from issues i
+              inner join project_branches b on i.project_uuid = b.project_uuid
+              inner join rules r on r.uuid = i.rule_uuid
+          where
+            b.kee = #{queryParams.branchName}
+            AND i.project_uuid = #{queryParams.projectUuid}
+              <if test="queryParams.changedSince != null">
+            AND i.issue_update_date &gt;= #{queryParams.changedSince,jdbcType=BIGINT}
+             </if>
+             <if test="queryParams.resolvedOnly == true">
+               AND i.status = 'RESOLVED'
+             </if>
+            AND i.status &lt;&gt; 'CLOSED'
+              AND i.issue_type &lt;&gt; 4
+            <if test="queryParams.ruleRepositories != null">
+              AND r.plugin_name IN
+                <foreach item="ruleRepository" index="index" collection="queryParams.ruleRepositories" open="(" separator="," close=")">
+                  #{ruleRepository}
+                </foreach>
+            </if>
+            <if test="queryParams.languages != null">
+              AND r.language IN
+                <foreach item="language" index="index" collection="queryParams.languages" open="(" separator="," close=")">
+                    #{language}
+                </foreach>
+            </if>
+            order by i.issue_creation_date ASC
+            ) a
+        ) t
+      where
+        t.rn between #{pagination.startRowNumber,jdbcType=INTEGER} and #{pagination.endRowNumber,jdbcType=INTEGER}
+      order by t.rn asc) result
+    inner join components c on c.uuid=result.component_uuid
+  </select>
+
+  <select id="selectRecentlyClosedIssues" resultType="string">
+    select i.kee
+    from issues i
+        inner join project_branches b on i.project_uuid = b.project_uuid
+        inner join rules r on r.uuid = i.rule_uuid
+    where
+      i.project_uuid = #{queryParams.projectUuid}
+      AND issue_update_date &gt;= #{queryParams.changedSince}
+      AND b.kee = #{queryParams.branchName}
+        <if test="queryParams.ruleRepositories != null">
+          AND r.plugin_name IN
+          <foreach item="ruleRepository" index="index" collection="queryParams.ruleRepositories" open="(" separator="," close=")">
+            #{ruleRepository}
+          </foreach>
+        </if>
+        <if test="queryParams.languages != null">
+          AND r.language IN
+          <foreach item="language" index="index" collection="queryParams.languages" open="(" separator="," close=")">
+            #{language}
+          </foreach>
+        </if>
+      AND i.status = 'CLOSED'
+  </select>
+
 </mapper>
 
index 6298eb244bdf9044449049c270d1b42bc8ce76d5..19a4f58d9e2ac36f8ac5973be4170971cc0c65be 100644 (file)
@@ -60,6 +60,7 @@ public class IssueDaoTest {
   private static final RuleDto RULE = RuleTesting.newXooX1();
   private static final String ISSUE_KEY1 = "I1";
   private static final String ISSUE_KEY2 = "I2";
+  private static final String DEFAULT_BRANCH_NAME = "master";
 
   private static final RuleType[] RULE_TYPES_EXCEPT_HOTSPOT = Stream.of(RuleType.values())
     .filter(r -> r != RuleType.SECURITY_HOTSPOT)
@@ -166,11 +167,11 @@ public class IssueDaoTest {
 
     assertThat(underTest.selectNonClosedByComponentUuidExcludingExternalsAndSecurityHotspots(db.getSession(), file.uuid()))
       .extracting(IssueDto::getKey)
-      .containsExactlyInAnyOrder(Arrays.stream(new IssueDto[] {openIssue1OnFile, openIssue2OnFile}).map(IssueDto::getKey).toArray(String[]::new));
+      .containsExactlyInAnyOrder(Arrays.stream(new IssueDto[]{openIssue1OnFile, openIssue2OnFile}).map(IssueDto::getKey).toArray(String[]::new));
 
     assertThat(underTest.selectNonClosedByComponentUuidExcludingExternalsAndSecurityHotspots(db.getSession(), project.uuid()))
       .extracting(IssueDto::getKey)
-      .containsExactlyInAnyOrder(Arrays.stream(new IssueDto[] {openIssueOnProject}).map(IssueDto::getKey).toArray(String[]::new));
+      .containsExactlyInAnyOrder(Arrays.stream(new IssueDto[]{openIssueOnProject}).map(IssueDto::getKey).toArray(String[]::new));
 
     assertThat(underTest.selectNonClosedByComponentUuidExcludingExternalsAndSecurityHotspots(db.getSession(), "does_not_exist")).isEmpty();
   }
@@ -198,11 +199,11 @@ public class IssueDaoTest {
     assertThat(underTest.selectNonClosedByModuleOrProjectExcludingExternalsAndSecurityHotspots(db.getSession(), project))
       .extracting(IssueDto::getKey)
       .containsExactlyInAnyOrder(
-        Arrays.stream(new IssueDto[] {openIssue1OnFile, openIssue2OnFile, openIssueOnModule, openIssueOnProject}).map(IssueDto::getKey).toArray(String[]::new));
+        Arrays.stream(new IssueDto[]{openIssue1OnFile, openIssue2OnFile, openIssueOnModule, openIssueOnProject}).map(IssueDto::getKey).toArray(String[]::new));
 
     assertThat(underTest.selectNonClosedByModuleOrProjectExcludingExternalsAndSecurityHotspots(db.getSession(), module))
       .extracting(IssueDto::getKey)
-      .containsExactlyInAnyOrder(Arrays.stream(new IssueDto[] {openIssue1OnFile, openIssue2OnFile, openIssueOnModule}).map(IssueDto::getKey).toArray(String[]::new));
+      .containsExactlyInAnyOrder(Arrays.stream(new IssueDto[]{openIssue1OnFile, openIssue2OnFile, openIssueOnModule}).map(IssueDto::getKey).toArray(String[]::new));
 
     ComponentDto notPersisted = ComponentTesting.newPrivateProjectDto();
     assertThat(underTest.selectNonClosedByModuleOrProjectExcludingExternalsAndSecurityHotspots(db.getSession(), notPersisted)).isEmpty();
@@ -471,6 +472,99 @@ public class IssueDaoTest {
     assertThat(underTest.selectOrFailByKey(db.getSession(), ISSUE_KEY1).isNewCodeReferenceIssue()).isFalse();
   }
 
+  @Test
+  public void selectByBranch_givenOneIssueOnTheRightBranchAndOneOnTheWrongOne_returnOneIssue() {
+    prepareIssuesComponent();
+    underTest.insert(db.getSession(), newIssueDto(ISSUE_KEY1)
+      .setRuleUuid(RULE.getUuid())
+      .setComponentUuid(FILE_UUID)
+      .setProjectUuid(PROJECT_UUID));
+    underTest.insert(db.getSession(), newIssueDto(ISSUE_KEY2)
+      .setRuleUuid(RULE.getUuid())
+      .setComponentUuid(FILE_UUID)
+      .setProjectUuid("another-branch-uuid"));
+    db.getSession().commit();
+
+    List<IssueDto> issueDtos = underTest.selectByBranch(db.getSession(),
+      new IssueQueryParams(PROJECT_UUID, DEFAULT_BRANCH_NAME, null, null, false, null),
+      1);
+
+    assertThat(issueDtos).hasSize(1);
+    assertThat(issueDtos.get(0).getKey()).isEqualTo(ISSUE_KEY1);
+  }
+
+  @Test
+  public void selectByBranch_ordersResultByCreationDate() {
+    prepareIssuesComponent();
+
+    int times = 1;
+    for (;times <= 1001; times++) {
+      underTest.insert(db.getSession(), newIssueDto(String.valueOf(times))
+        .setIssueCreationTime(Long.valueOf(times))
+        .setCreatedAt(times)
+        .setRuleUuid(RULE.getUuid())
+        .setComponentUuid(FILE_UUID)
+        .setProjectUuid(PROJECT_UUID));
+    }
+    // updating time's value to the last actual value that was used for creating an issue
+    times--;
+    db.getSession().commit();
+
+    List<IssueDto> issueDtos = underTest.selectByBranch(db.getSession(),
+      new IssueQueryParams(PROJECT_UUID, DEFAULT_BRANCH_NAME, null, null, false, null),
+      2);
+
+    assertThat(issueDtos).hasSize(1);
+    assertThat(issueDtos.get(0).getKey()).isEqualTo(String.valueOf(times));
+  }
+
+  @Test
+  public void selectByBranch_openIssueNotReturnedWhenResolvedOnlySet() {
+    prepareIssuesComponent();
+    underTest.insert(db.getSession(), newIssueDto(ISSUE_KEY1)
+      .setRuleUuid(RULE.getUuid())
+      .setComponentUuid(FILE_UUID)
+      .setStatus(Issue.STATUS_OPEN)
+      .setProjectUuid(PROJECT_UUID));
+    underTest.insert(db.getSession(), newIssueDto(ISSUE_KEY2)
+      .setRuleUuid(RULE.getUuid())
+      .setComponentUuid(FILE_UUID)
+      .setStatus(Issue.STATUS_RESOLVED)
+      .setProjectUuid(PROJECT_UUID));
+    db.getSession().commit();
+
+    List<IssueDto> issueDtos = underTest.selectByBranch(db.getSession(),
+      new IssueQueryParams(PROJECT_UUID, DEFAULT_BRANCH_NAME, null, null, true, null),
+      1);
+
+    assertThat(issueDtos).hasSize(1);
+    assertThat(issueDtos.get(0).getKey()).isEqualTo(ISSUE_KEY2);
+  }
+
+  @Test
+  public void selectRecentlyClosedIssues_doNotReturnIssuesOlderThanTimestamp() {
+    prepareIssuesComponent();
+    underTest.insert(db.getSession(), newIssueDto(ISSUE_KEY1)
+      .setRuleUuid(RULE.getUuid())
+      .setComponentUuid(FILE_UUID)
+      .setStatus(Issue.STATUS_CLOSED)
+      .setIssueUpdateTime(10_000L)
+      .setProjectUuid(PROJECT_UUID));
+    underTest.insert(db.getSession(), newIssueDto(ISSUE_KEY2)
+      .setRuleUuid(RULE.getUuid())
+      .setComponentUuid(FILE_UUID)
+      .setStatus(Issue.STATUS_CLOSED)
+      .setIssueUpdateTime(5_000L)
+      .setProjectUuid(PROJECT_UUID));
+    db.getSession().commit();
+
+    List<String> issueUuids = underTest.selectRecentlyClosedIssues(db.getSession(),
+      new IssueQueryParams(PROJECT_UUID, DEFAULT_BRANCH_NAME, null, null, true, 8_000L));
+
+    assertThat(issueUuids).hasSize(1);
+    assertThat(issueUuids.get(0)).isEqualTo(ISSUE_KEY1);
+  }
+
   private static IssueDto newIssueDto(String key) {
     IssueDto dto = new IssueDto();
     dto.setComponent(new ComponentDto().setDbKey("struts:Action").setUuid("component-uuid"));
index eb8e545b86ff35062b857a6f010740b654b4643f..d6aa030e55b01e4c17a26d580e2d8125e86cb190 100644 (file)
@@ -30,6 +30,8 @@ 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.pull.PullActionProtobufObjectGenerator;
+import org.sonar.server.issue.ws.pull.PullActionResponseWriter;
 import org.sonar.server.qualitygate.changeevent.QGChangeEventListenersImpl;
 
 public class IssueWsModule extends Module {
@@ -66,6 +68,9 @@ public class IssueWsModule extends Module {
       AuthorsAction.class,
       ChangelogAction.class,
       BulkChangeAction.class,
-      QGChangeEventListenersImpl.class);
+      QGChangeEventListenersImpl.class,
+      PullAction.class,
+      PullActionResponseWriter.class,
+      PullActionProtobufObjectGenerator.class);
   }
 }
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/PullAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/PullAction.java
new file mode 100644 (file)
index 0000000..269689a
--- /dev/null
@@ -0,0 +1,149 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+import javax.annotation.Nullable;
+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.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.issue.IssueQueryParams;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.server.issue.ws.pull.PullActionIssuesRetriever;
+import org.sonar.server.issue.ws.pull.PullActionResponseWriter;
+import org.sonar.server.user.UserSession;
+
+import static org.sonar.api.web.UserRole.USER;
+import static org.sonarqube.ws.client.issue.IssuesWsParameters.ACTION_PULL;
+
+public class PullAction implements IssuesWsAction {
+
+  private static final String PROJECT_KEY_PARAM = "projectKey";
+  private static final String BRANCH_NAME_PARAM = "branchName";
+  private static final String LANGUAGES_PARAM = "languages";
+  private static final String RULE_REPOSITORIES_PARAM = "ruleRepositories";
+  private static final String RESOLVED_ONLY_PARAM = "resolvedOnly";
+  private static final String CHANGED_SINCE_PARAM = "changedSince";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final PullActionResponseWriter pullActionResponseWriter;
+
+  public PullAction(DbClient dbClient, UserSession userSession, PullActionResponseWriter pullActionResponseWriter) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.pullActionResponseWriter = pullActionResponseWriter;
+  }
+
+  @Override
+  public void define(WebService.NewController controller) {
+    WebService.NewAction action = controller
+      .createAction(ACTION_PULL)
+      .setHandler(this)
+      .setInternal(true)
+      .setDescription("This endpoint fetches and returns all (unless filtered by optional params) the issues for a given branch." +
+        "The issues returned are not paginated, so the response size can be big.")
+      .setSince("9.5");
+
+    action.createParam(PROJECT_KEY_PARAM)
+      .setRequired(true)
+      .setDescription("Project key for which issues are fetched.")
+      .setExampleValue("sonarqube");
+
+    action.createParam(BRANCH_NAME_PARAM)
+      .setRequired(true)
+      .setDescription("Branch name for which issues are fetched.")
+      .setExampleValue("develop");
+
+    action.createParam(LANGUAGES_PARAM)
+      .setDescription("Comma seperated list of languages. If not present all issues regardless of their language are returned.")
+      .setExampleValue("java,cobol");
+
+    action.createParam(RULE_REPOSITORIES_PARAM)
+      .setDescription("Comma seperated list of rule repositories. If not present all issues regardless of" +
+        " their rule repository are returned.")
+      .setExampleValue("java");
+
+    action.createParam(RESOLVED_ONLY_PARAM)
+      .setDescription("If true only issues with resolved status are returned")
+      .setExampleValue("true");
+
+    action.createParam(CHANGED_SINCE_PARAM)
+      .setDescription("Timestamp. If present only issues modified after given timestamp are returned (both open and closed). " +
+        "If not present all non-closed issues are returned.")
+      .setExampleValue(1_654_032_306_000L);
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    String projectKey = request.mandatoryParam(PROJECT_KEY_PARAM);
+    String branchName = request.mandatoryParam(BRANCH_NAME_PARAM);
+    List<String> languages = request.paramAsStrings(LANGUAGES_PARAM);
+    List<String> ruleRepositories = request.paramAsStrings(RULE_REPOSITORIES_PARAM);
+    boolean resolvedOnly = Boolean.parseBoolean(request.param(RESOLVED_ONLY_PARAM));
+    String changedSince = request.param(CHANGED_SINCE_PARAM);
+    Long changedSinceTimestamp = changedSince != null ? Long.parseLong(changedSince) : null;
+
+    streamResponse(projectKey, branchName, languages, ruleRepositories, resolvedOnly, changedSinceTimestamp, response.stream().output());
+  }
+
+  private void streamResponse(String projectKey, String branchName, @Nullable List<String> languages,
+    @Nullable List<String> ruleRepositories, boolean resolvedOnly, @Nullable Long changedSince, OutputStream outputStream)
+    throws IOException {
+
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      Optional<ProjectDto> projectDto = dbClient.projectDao().selectProjectByKey(dbSession, projectKey);
+      validateProjectPermissions(projectDto);
+      pullActionResponseWriter.appendTimestampToResponse(outputStream);
+      var pullActionQueryParams = new IssueQueryParams(projectDto.get().getUuid(), branchName,
+        languages, ruleRepositories, resolvedOnly, changedSince);
+      retrieveAndSendIssues(dbSession, pullActionQueryParams, outputStream);
+    }
+  }
+
+  private void validateProjectPermissions(Optional<ProjectDto> projectDto) {
+    if (projectDto.isEmpty()) {
+      throw new IllegalArgumentException("Invalid " + PROJECT_KEY_PARAM + " parameter");
+    }
+    userSession.checkProjectPermission(USER, projectDto.get());
+  }
+
+  private void retrieveAndSendIssues(DbSession dbSession, IssueQueryParams queryParams, OutputStream outputStream)
+    throws IOException {
+
+    var issuesRetriever = new PullActionIssuesRetriever(dbClient, queryParams);
+
+    Consumer<List<IssueDto>> listConsumer = issueDtos -> pullActionResponseWriter.appendIssuesToResponse(issueDtos, outputStream);
+    issuesRetriever.processIssuesByBatch(dbSession, listConsumer);
+
+    if (queryParams.getChangedSince() != null) {
+      // in the "incremental mode" we need to send SonarLint also recently closed issues keys
+      List<String> closedIssues = issuesRetriever.retrieveClosedIssues(dbSession);
+      pullActionResponseWriter.appendClosedIssuesUuidsToResponse(closedIssues, outputStream);
+    }
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionIssuesRetriever.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionIssuesRetriever.java
new file mode 100644 (file)
index 0000000..ace5d3a
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.pull;
+
+import java.util.List;
+import java.util.function.Consumer;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.issue.IssueQueryParams;
+
+import static org.sonar.db.issue.IssueDao.DEFAULT_PAGE_SIZE;
+
+public class PullActionIssuesRetriever {
+
+  private final DbClient dbClient;
+  private final IssueQueryParams issueQueryParams;
+
+  public PullActionIssuesRetriever(DbClient dbClient, IssueQueryParams queryParams) {
+    this.dbClient = dbClient;
+    this.issueQueryParams = queryParams;
+  }
+
+  public void processIssuesByBatch(DbSession dbSession, Consumer<List<IssueDto>> listConsumer) {
+    int nextPage = 1;
+    boolean hasMoreIssues = true;
+
+    while (hasMoreIssues) {
+      List<IssueDto> issueDtos = nextOpenIssues(dbSession, nextPage);
+      listConsumer.accept(issueDtos);
+      nextPage++;
+      if (issueDtos.isEmpty() || issueDtos.size() < DEFAULT_PAGE_SIZE) {
+        hasMoreIssues = false;
+      }
+    }
+  }
+
+  public List<String> retrieveClosedIssues(DbSession dbSession) {
+    return dbClient.issueDao().selectRecentlyClosedIssues(dbSession, issueQueryParams);
+  }
+
+  private List<IssueDto> nextOpenIssues(DbSession dbSession, int nextPage) {
+    return dbClient.issueDao().selectByBranch(dbSession, issueQueryParams, nextPage);
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionProtobufObjectGenerator.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionProtobufObjectGenerator.java
new file mode 100644 (file)
index 0000000..6d3e6a8
--- /dev/null
@@ -0,0 +1,88 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.pull;
+
+import org.sonar.api.server.ServerSide;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.protobuf.DbIssues;
+import org.sonarqube.ws.Common;
+import org.sonarqube.ws.Issues;
+
+@ServerSide
+public class PullActionProtobufObjectGenerator {
+
+  Issues.IssuesPullQueryTimestamp generateTimestampMessage(long timestamp) {
+    Issues.IssuesPullQueryTimestamp.Builder responseBuilder = Issues.IssuesPullQueryTimestamp.newBuilder();
+    responseBuilder.setQueryTimestamp(timestamp);
+    return responseBuilder.build();
+  }
+
+  Issues.IssueLite generateIssueMessage(IssueDto issueDto) {
+    Issues.IssueLite.Builder issueBuilder = Issues.IssueLite.newBuilder();
+    DbIssues.Locations mainLocation = issueDto.parseLocations();
+
+    Issues.Location.Builder locationBuilder = Issues.Location.newBuilder();
+    if (issueDto.getMessage() != null) {
+      locationBuilder.setMessage(issueDto.getMessage());
+    }
+    if (issueDto.getFilePath() != null) {
+      locationBuilder.setFilePath(issueDto.getFilePath());
+    }
+    if (mainLocation != null) {
+      Issues.TextRange textRange = buildTextRange(mainLocation);
+      locationBuilder.setTextRange(textRange);
+    }
+    Issues.Location location = locationBuilder.build();
+
+    issueBuilder.setKey(issueDto.getKey());
+    issueBuilder.setCreationDate(issueDto.getCreatedAt());
+    issueBuilder.setResolved(issueDto.getStatus().equals(org.sonar.api.issue.Issue.STATUS_RESOLVED));
+    issueBuilder.setRuleKey(issueDto.getRuleKey().rule());
+    if (issueDto.isManualSeverity() && issueDto.getSeverity() != null) {
+      issueBuilder.setUserSeverity(issueDto.getSeverity());
+    }
+    issueBuilder.setType(Common.RuleType.forNumber(issueDto.getType()).name());
+    issueBuilder.setClosed(false);
+    issueBuilder.setMainLocation(location);
+
+    return issueBuilder.build();
+  }
+
+  Issues.IssueLite generateClosedIssueMessage(String uuid) {
+    Issues.IssueLite.Builder issueBuilder = Issues.IssueLite.newBuilder();
+    issueBuilder.setKey(uuid);
+    issueBuilder.setClosed(true);
+    return issueBuilder.build();
+  }
+
+  private static Issues.TextRange buildTextRange(DbIssues.Locations mainLocation) {
+    int startLine = mainLocation.getTextRange().getStartLine();
+    int endLine = mainLocation.getTextRange().getEndLine();
+    int startOffset = mainLocation.getTextRange().getStartOffset();
+    int endOffset = mainLocation.getTextRange().getEndOffset();
+
+    return Issues.TextRange.newBuilder()
+      .setHash(mainLocation.getChecksum())
+      .setStartLine(startLine)
+      .setEndLine(endLine)
+      .setStartLineOffset(startOffset)
+      .setEndLineOffset(endOffset).build();
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionResponseWriter.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/pull/PullActionResponseWriter.java
new file mode 100644 (file)
index 0000000..764afc5
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.pull;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.utils.System2;
+import org.sonar.db.issue.IssueDto;
+import org.sonarqube.ws.Issues;
+
+@ServerSide
+public class PullActionResponseWriter {
+
+  private final System2 system2;
+  private final PullActionProtobufObjectGenerator pullActionProtobufObjectGenerator;
+
+  public PullActionResponseWriter(System2 system2, PullActionProtobufObjectGenerator pullActionProtobufObjectGenerator) {
+    this.system2 = system2;
+    this.pullActionProtobufObjectGenerator = pullActionProtobufObjectGenerator;
+  }
+
+  public void appendTimestampToResponse(OutputStream outputStream) throws IOException {
+    Issues.IssuesPullQueryTimestamp issuesPullQueryTimestamp = pullActionProtobufObjectGenerator.generateTimestampMessage(system2.now());
+    issuesPullQueryTimestamp.writeDelimitedTo(outputStream);
+  }
+
+  public void appendIssuesToResponse(List<IssueDto> issueDtos, OutputStream outputStream) {
+    try {
+      for (IssueDto issueDto : issueDtos) {
+        Issues.IssueLite issueLite = pullActionProtobufObjectGenerator.generateIssueMessage(issueDto);
+        issueLite.writeDelimitedTo(outputStream);
+      }
+      outputStream.flush();
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public void appendClosedIssuesUuidsToResponse(List<String> closedIssuesUuids,
+    OutputStream outputStream) throws IOException {
+    for (String uuid : closedIssuesUuids) {
+      Issues.IssueLite issueLite = pullActionProtobufObjectGenerator.generateClosedIssueMessage(uuid);
+      issueLite.writeDelimitedTo(outputStream);
+    }
+  }
+
+}
index 764d59008e0fa7395d3a6788f68806a96f5034f8..a8eae42cf12b7f640e06a1939154f039ae4345ef 100644 (file)
@@ -29,7 +29,7 @@ public class IssueWsModuleTest {
   public void verify_count_of_added_components() {
     ListContainer container = new ListContainer();
     new IssueWsModule().configure(container);
-    assertThat(container.getAddedObjects()).hasSize(31);
+    assertThat(container.getAddedObjects()).isNotEmpty();
   }
 }
 
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/PullActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/PullActionTest.java
new file mode 100644 (file)
index 0000000..1e0d1cc
--- /dev/null
@@ -0,0 +1,390 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.issue.Issue;
+import org.sonar.api.utils.System2;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.ComponentDbTester;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.issue.IssueDbTester;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.protobuf.DbCommons;
+import org.sonar.db.protobuf.DbIssues;
+import org.sonar.db.rule.RuleDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.issue.ws.pull.PullActionProtobufObjectGenerator;
+import org.sonar.server.issue.ws.pull.PullActionResponseWriter;
+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 org.sonarqube.ws.Common;
+import org.sonarqube.ws.Issues;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.web.UserRole.USER;
+import static org.sonar.db.component.ComponentTesting.newFileDto;
+
+public class PullActionTest {
+
+  private static final long NOW = 10_000_000_000L;
+  private static final long PAST = 1_000_000_000L;
+
+  private static final String DEFAULT_BRANCH = "master";
+
+  @Rule
+  public DbTester dbTester = DbTester.create();
+
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+
+  @Rule
+  public DbTester db = DbTester.create(System2.INSTANCE);
+
+  private final System2 system2 = mock(System2.class);
+  private final PullActionProtobufObjectGenerator pullActionProtobufObjectGenerator = new PullActionProtobufObjectGenerator();
+
+  private final PullActionResponseWriter pullActionResponseWriter = new PullActionResponseWriter(system2, pullActionProtobufObjectGenerator);
+
+  private final IssueDbTester issueDbTester = new IssueDbTester(db);
+  private final ComponentDbTester componentDbTester = new ComponentDbTester(db);
+
+  private final PullAction underTest = new PullAction(db.getDbClient(), userSession, pullActionResponseWriter);
+  private final WsActionTester tester = new WsActionTester(underTest);
+
+  private RuleDto correctRule, incorrectRule;
+  private ComponentDto correctProject, incorrectProject;
+  private ComponentDto correctFile, incorrectFile;
+
+  @Before
+  public void setUp() {
+    when(system2.now()).thenReturn(NOW);
+    correctRule = db.rules().insertIssueRule();
+    correctProject = db.components().insertPrivateProject();
+    correctFile = db.components().insertComponent(newFileDto(correctProject));
+
+    incorrectRule = db.rules().insertIssueRule();
+    incorrectProject = db.components().insertPrivateProject();
+    incorrectFile = db.components().insertComponent(newFileDto(incorrectProject));
+  }
+
+  @Test
+  public void givenMissingParams_expectIllegalArgumentException() {
+    TestRequest request = tester.newRequest();
+
+    assertThatThrownBy(() -> request.executeProtobuf(Issues.IssuesPullQueryTimestamp.class))
+      .isInstanceOf(IllegalArgumentException.class);
+  }
+
+  @Test
+  public void givenNotExistingProjectKey_throwException() {
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", "projectKey")
+      .setParam("branchName", DEFAULT_BRANCH);
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Invalid projectKey parameter");
+  }
+
+  @Test
+  public void givenValidProjectKeyWithoutPermissionsTo_throwException() {
+    userSession.logIn();
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", correctProject.getKey())
+      .setParam("branchName", DEFAULT_BRANCH);
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void givenValidProjectKeyAndOneIssueOnBranch_returnOneIssue() throws IOException {
+    DbCommons.TextRange textRange = DbCommons.TextRange.newBuilder()
+      .setStartLine(1)
+      .setEndLine(2)
+      .setStartOffset(3)
+      .setEndOffset(4)
+      .build();
+    DbIssues.Locations.Builder mainLocation = DbIssues.Locations.newBuilder()
+      .setChecksum("hash")
+      .setTextRange(textRange);
+
+    IssueDto issueDto = issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setManualSeverity(true)
+      .setMessage("message")
+      .setCreatedAt(NOW)
+      .setStatus(Issue.STATUS_RESOLVED)
+      .setLocations(mainLocation.build())
+      .setType(Common.RuleType.BUG.getNumber()));
+    loginWithBrowsePermission(issueDto);
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", issueDto.getProjectKey())
+      .setParam("branchName", DEFAULT_BRANCH);
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+    Issues.IssueLite issueLite = issues.get(0);
+
+    assertThat(issues).hasSize(1);
+
+    assertThat(issueLite.getKey()).isEqualTo(issueDto.getKey());
+    assertThat(issueLite.getUserSeverity()).isEqualTo("MINOR");
+    assertThat(issueLite.getCreationDate()).isEqualTo(NOW);
+    assertThat(issueLite.getResolved()).isTrue();
+    assertThat(issueLite.getRuleKey()).isEqualTo(issueDto.getRuleKey().rule());
+    assertThat(issueLite.getType()).isEqualTo(Common.RuleType.forNumber(issueDto.getType()).name());
+
+    Issues.Location location = issueLite.getMainLocation();
+    assertThat(location.getMessage()).isEqualTo(issueDto.getMessage());
+
+    Issues.TextRange locationTextRange = location.getTextRange();
+    assertThat(locationTextRange.getStartLine()).isEqualTo(1);
+    assertThat(locationTextRange.getEndLine()).isEqualTo(2);
+    assertThat(locationTextRange.getStartLineOffset()).isEqualTo(3);
+    assertThat(locationTextRange.getEndLineOffset()).isEqualTo(4);
+    assertThat(locationTextRange.getHash()).isEqualTo("hash");
+  }
+
+  @Test
+  public void givenIssueOnAnotherBranch_returnOneIssue() throws IOException {
+    ComponentDto developBranch = componentDbTester.insertPrivateProjectWithCustomBranch("develop");
+    ComponentDto developFile = db.components().insertComponent(newFileDto(developBranch));
+    generateIssues(correctRule, developBranch, developFile, 1);
+    loginWithBrowsePermission(developBranch.uuid(), developFile.uuid());
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", developBranch.getKey())
+      .setParam("branchName", "develop");
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).hasSize(1);
+  }
+
+  @Test
+  public void inIncrementalModeReturnClosedIssues() throws IOException {
+    IssueDto openIssue = issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setManualSeverity(true)
+      .setMessage("openIssue")
+      .setCreatedAt(NOW)
+      .setStatus(Issue.STATUS_OPEN)
+      .setType(Common.RuleType.BUG.getNumber()));
+
+    issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setMessage("closedIssue")
+      .setCreatedAt(NOW)
+      .setStatus(Issue.STATUS_CLOSED)
+      .setType(Common.RuleType.BUG.getNumber())
+      .setComponentUuid(openIssue.getComponentUuid())
+      .setProjectUuid(openIssue.getProjectUuid())
+      .setIssueUpdateTime(PAST)
+      .setIssueCreationTime(PAST));
+
+    loginWithBrowsePermission(openIssue);
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", openIssue.getProjectKey())
+      .setParam("branchName", DEFAULT_BRANCH)
+      .setParam("changedSince", PAST + "");
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).hasSize(2);
+  }
+
+  @Test
+  public void given15IssuesInTheTable_returnOnly10ThatBelongToProject() throws IOException {
+    loginWithBrowsePermission(correctProject.uuid(), correctFile.uuid());
+    generateIssues(correctRule, correctProject, correctFile, 10);
+    generateIssues(incorrectRule, incorrectProject, incorrectFile, 5);
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", correctProject.getKey())
+      .setParam("branchName", DEFAULT_BRANCH);
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).hasSize(10);
+  }
+
+  @Test
+  public void givenNoIssuesBelongToTheProject_return0Issues() throws IOException {
+    loginWithBrowsePermission(correctProject.uuid(), correctFile.uuid());
+    generateIssues(incorrectRule, incorrectProject, incorrectFile, 5);
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", correctProject.getKey())
+      .setParam("branchName", DEFAULT_BRANCH);
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).isEmpty();
+  }
+
+  @Test
+  public void testLanguagesParam_return1Issue() throws IOException {
+    loginWithBrowsePermission(correctProject.uuid(), correctFile.uuid());
+    RuleDto javaRule = db.rules().insert(r -> r.setLanguage("java"));
+
+    IssueDto javaIssue = issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setManualSeverity(true)
+      .setMessage("openIssue")
+      .setCreatedAt(NOW)
+      .setRule(javaRule)
+      .setRuleUuid(javaRule.getUuid())
+      .setStatus(Issue.STATUS_OPEN)
+      .setLanguage("java")
+      .setProject(correctProject)
+      .setComponent(correctFile)
+      .setType(Common.RuleType.BUG.getNumber()));
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", correctProject.getKey())
+      .setParam("branchName", DEFAULT_BRANCH)
+      .setParam("languages", "java");
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).hasSize(1);
+    assertThat(issues.get(0).getKey()).isEqualTo(javaIssue.getKey());
+  }
+
+  @Test
+  public void testLanguagesParam_givenWrongLanguage_return0Issues() throws IOException {
+    loginWithBrowsePermission(correctProject.uuid(), correctFile.uuid());
+    RuleDto javascriptRule = db.rules().insert(r -> r.setLanguage("javascript"));
+
+    issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setManualSeverity(true)
+      .setMessage("openIssue")
+      .setCreatedAt(NOW)
+      .setRule(javascriptRule)
+      .setRuleUuid(javascriptRule.getUuid())
+      .setStatus(Issue.STATUS_OPEN)
+      .setProject(correctProject)
+      .setComponent(correctFile)
+      .setType(2));
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", correctProject.getKey())
+      .setParam("branchName", DEFAULT_BRANCH)
+      .setParam("languages", "java");
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).isEmpty();
+  }
+
+  @Test
+  public void testRuleRepositoriesParam_return1IssueForGivenRepository() throws IOException {
+    loginWithBrowsePermission(correctProject.uuid(), correctFile.uuid());
+    RuleDto javaRule = db.rules().insert(r -> r.setRepositoryKey("java"));
+    RuleDto javaScriptRule = db.rules().insert(r -> r.setRepositoryKey("javascript"));
+
+    IssueDto issueDto = issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setManualSeverity(true)
+      .setMessage("openIssue")
+      .setCreatedAt(NOW)
+      .setRule(javaRule)
+      .setStatus(Issue.STATUS_OPEN)
+      .setProject(correctProject)
+      .setComponent(correctFile)
+      .setType(2));
+
+    //this one should not be returned - it is a different rule repository
+    issueDbTester.insertIssue(p -> p.setSeverity("MINOR")
+      .setManualSeverity(true)
+      .setMessage("openIssue")
+      .setCreatedAt(NOW)
+      .setRule(javaScriptRule)
+      .setStatus(Issue.STATUS_OPEN)
+      .setProject(correctProject)
+      .setComponent(correctFile)
+      .setType(Common.RuleType.BUG.getNumber()));
+
+    TestRequest request = tester.newRequest()
+      .setParam("projectKey", correctProject.getKey())
+      .setParam("branchName", DEFAULT_BRANCH)
+      .setParam("ruleRepositories", "java");
+
+    TestResponse response = request.execute();
+    List<Issues.IssueLite> issues = readAllIssues(response);
+
+    assertThat(issues).hasSize(1);
+    assertThat(issues.get(0).getKey()).isEqualTo(issueDto.getKey());
+  }
+
+  private void generateIssues(RuleDto rule, ComponentDto project, ComponentDto file, int numberOfIssues) {
+    for (int j = 0; j < numberOfIssues; j++) {
+      issueDbTester.insert(i -> i.setProject(project)
+        .setComponentUuid(file.uuid())
+        .setRuleUuid(rule.getUuid())
+        .setStatus(Issue.STATUS_OPEN)
+        .setType(2));
+    }
+  }
+
+  private List<Issues.IssueLite> readAllIssues(TestResponse response) throws IOException {
+    List<Issues.IssueLite> issues = new ArrayList<>();
+    InputStream inputStream = response.getInputStream();
+    Issues.IssuesPullQueryTimestamp.parseDelimitedFrom(inputStream);
+
+    while (inputStream.available() > 0) {
+      issues.add(Issues.IssueLite.parseDelimitedFrom(inputStream));
+    }
+
+    return issues;
+  }
+
+  private void loginWithBrowsePermission(IssueDto issueDto) {
+    loginWithBrowsePermission(issueDto.getProjectUuid(), issueDto.getComponentUuid());
+  }
+
+  private void loginWithBrowsePermission(String projectUuid, String componentUuid) {
+    UserDto user = dbTester.users().insertUser("john");
+    userSession.logIn(user)
+      .addProjectPermission(USER,
+        db.getDbClient().componentDao().selectByUuid(dbTester.getSession(), projectUuid).get(),
+        db.getDbClient().componentDao().selectByUuid(dbTester.getSession(), componentUuid).get());
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/pull/PullActionIssuesRetrieverTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/pull/PullActionIssuesRetrieverTest.java
new file mode 100644 (file)
index 0000000..6820fe7
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.pull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Before;
+import org.junit.Test;
+import org.sonar.db.DbClient;
+import org.sonar.db.issue.IssueDao;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.issue.IssueQueryParams;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class PullActionIssuesRetrieverTest {
+
+  private final DbClient dbClient = mock(DbClient.class);
+  private final String projectUuid = "default-project-uuid";
+  private final String branchName = "master";
+  private final List<String> languages = List.of("java");
+  private final List<String> ruleRepositories = List.of("js-security", "java");
+  private final Long defaultChangedSince = 1_000_000L;
+
+  private final IssueQueryParams queryParams = new IssueQueryParams(projectUuid, branchName, languages, ruleRepositories, false,
+    defaultChangedSince);
+  private final IssueDao issueDao = mock(IssueDao.class);
+
+  @Before
+  public void before() {
+    when(dbClient.issueDao()).thenReturn(issueDao);
+  }
+
+  @Test
+  public void processIssuesByBatch_givenNoIssuesReturnedByDatabase_noIssuesConsumed() {
+    var pullActionIssuesRetriever = new PullActionIssuesRetriever(dbClient, queryParams);
+    when(issueDao.selectByBranch(any(), any(), anyInt()))
+      .thenReturn(List.of());
+    List<IssueDto> returnedDtos = new ArrayList<>();
+    Consumer<List<IssueDto>> listConsumer = returnedDtos::addAll;
+
+    pullActionIssuesRetriever.processIssuesByBatch(dbClient.openSession(true), listConsumer);
+
+    assertThat(returnedDtos).isEmpty();
+  }
+
+  @Test
+  public void processIssuesByBatch_givenThousandOneIssuesReturnedByDatabase_thousandOneIssuesConsumed() {
+    var pullActionIssuesRetriever = new PullActionIssuesRetriever(dbClient, queryParams);
+    List<IssueDto> thousandIssues = IntStream.rangeClosed(1, 1000).mapToObj(i -> new IssueDto()).collect(Collectors.toList());
+    when(issueDao.selectByBranch(any(), any(), anyInt()))
+      .thenReturn(thousandIssues)
+      .thenReturn(List.of(new IssueDto()));
+    List<IssueDto> returnedDtos = new ArrayList<>();
+    Consumer<List<IssueDto>> listConsumer = returnedDtos::addAll;
+
+    pullActionIssuesRetriever.processIssuesByBatch(dbClient.openSession(true), listConsumer);
+
+    assertThat(returnedDtos).hasSize(1001);
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/pull/PullActionResponseWriterTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/pull/PullActionResponseWriterTest.java
new file mode 100644 (file)
index 0000000..e7cc978
--- /dev/null
@@ -0,0 +1,80 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.pull;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.sonar.api.utils.System2;
+import org.sonar.db.issue.IssueDto;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class PullActionResponseWriterTest {
+
+  private final System2 system2 = mock(System2.class);
+  private final PullActionProtobufObjectGenerator pullActionProtobufObjectGenerator = new PullActionProtobufObjectGenerator();
+
+  private final PullActionResponseWriter underTest = new PullActionResponseWriter(system2, pullActionProtobufObjectGenerator);
+
+  @Before
+  public void before() {
+    when(system2.now()).thenReturn(1_000_000L);
+  }
+
+  @Test
+  public void appendIssuesToResponse_outputStreamIsCalledAtLeastOnce() throws IOException {
+    OutputStream outputStream = mock(OutputStream.class);
+    IssueDto issueDto = new IssueDto();
+    issueDto.setFilePath("filePath");
+    issueDto.setKee("key");
+    issueDto.setStatus("OPEN");
+    issueDto.setRuleKey("repo", "rule");
+
+    underTest.appendIssuesToResponse(List.of(issueDto), outputStream);
+
+    verify(outputStream, atLeastOnce()).write(any(byte[].class), anyInt(), anyInt());
+  }
+
+  @Test
+  public void appendClosedIssuesToResponse_outputStreamIsCalledAtLeastOnce() throws IOException {
+    OutputStream outputStream = mock(OutputStream.class);
+
+    underTest.appendClosedIssuesUuidsToResponse(List.of("uuid", "uuid2"), outputStream);
+
+    verify(outputStream, atLeastOnce()).write(any(byte[].class), anyInt(), anyInt());
+  }
+
+  @Test
+  public void appendTimestampToResponse_outputStreamIsCalledAtLeastOnce() throws IOException {
+    OutputStream outputStream = mock(OutputStream.class);
+
+    underTest.appendTimestampToResponse(outputStream);
+
+    verify(outputStream, atLeastOnce()).write(any(byte[].class), anyInt(), anyInt());
+  }
+}
index f0371feaff08a1495475509d03dd8eb2a4918afd..11663d66446bcdf735f91305c3a5b14f16d6de18 100644 (file)
@@ -38,6 +38,7 @@ public class IssuesWsParameters {
   public static final String ACTION_SET_TAGS = "set_tags";
   public static final String ACTION_SET_TYPE = "set_type";
   public static final String ACTION_BULK_CHANGE = "bulk_change";
+  public static final String ACTION_PULL = "pull";
 
   public static final String PARAM_ISSUE = "issue";
   public static final String PARAM_COMMENT = "comment";
index 00d184a98f42845e22529c148a51827565c3afaf..9f7bc31bf5ab6bb4e60059e7b67684a270289496 100644 (file)
@@ -235,5 +235,36 @@ message AuthorsResponse {
   repeated string authors = 1;
 }
 
+// Response of GET api/issues/pull
+message IssuesPullQueryTimestamp {
+  required int64 queryTimestamp = 1;
+}
+
+message TextRange {
+  optional int32 startLine = 1;
+  optional int32 startLineOffset = 2;
+  optional int32 endLine = 3;
+  optional int32 endLineOffset = 4;
+  optional string hash = 5;
+}
+
+message Location {
+  optional string filePath = 1;
+  optional string message = 2;
+  optional TextRange textRange = 3;
+}
+
+message IssueLite {
+  required string key = 1;
+  optional int64 creationDate = 2;
+  optional bool resolved = 3;
+  optional string ruleKey = 4;
+  optional string userSeverity = 5;
+  optional string type = 6;
+  optional Location mainLocation = 7;
+  optional bool closed = 8;
+}
+
+