aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>2018-08-16 14:20:18 +0200
committerSonarTech <sonartech@sonarsource.com>2018-08-21 20:21:05 +0200
commit9e0cbc111e2575e13b0b0cda3018432b1c95a10b (patch)
tree9acaa9911806bfb43bdc0bd5159d0a6e3f3dcc27
parent8b7e1456368921a8e7cebde1eebf02832dd8e5b5 (diff)
downloadsonarqube-9e0cbc111e2575e13b0b0cda3018432b1c95a10b.tar.gz
sonarqube-9e0cbc111e2575e13b0b0cda3018432b1c95a10b.zip
SONAR-8368 restore resolution when reopening closed issues
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/IssueWorkflow.java8
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/PreviousStatusWas.java1
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/RestoreResolutionFunction.java86
-rw-r--r--server/sonar-server-common/src/test/java/org/sonar/server/issue/workflow/IssueWorkflowTest.java144
-rw-r--r--sonar-core/src/main/java/org/sonar/core/issue/FieldDiffs.java8
-rw-r--r--sonar-core/src/test/java/org/sonar/core/issue/FieldDiffsTest.java10
6 files changed, 227 insertions, 30 deletions
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/IssueWorkflow.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/IssueWorkflow.java
index 052acd43f9a..95694c99809 100644
--- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/IssueWorkflow.java
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/IssueWorkflow.java
@@ -250,7 +250,7 @@ public class IssueWorkflow implements Startable {
new PreviousStatusWas(Issue.STATUS_OPEN),
new HasResolution(Issue.RESOLUTION_REMOVED, Issue.RESOLUTION_FIXED),
IsNotHotspotNorManualVulnerability.INSTANCE)
- .functions(UnsetCloseDate.INSTANCE)
+ .functions(RestoreResolutionFunction.INSTANCE, UnsetCloseDate.INSTANCE)
.automatic()
.build())
.transition(Transition.builder("automaticunclosereopen")
@@ -259,7 +259,7 @@ public class IssueWorkflow implements Startable {
new PreviousStatusWas(Issue.STATUS_REOPENED),
new HasResolution(Issue.RESOLUTION_REMOVED, Issue.RESOLUTION_FIXED),
IsNotHotspotNorManualVulnerability.INSTANCE)
- .functions(UnsetCloseDate.INSTANCE)
+ .functions(RestoreResolutionFunction.INSTANCE, UnsetCloseDate.INSTANCE)
.automatic()
.build())
.transition(Transition.builder("automaticuncloseconfirmed")
@@ -268,7 +268,7 @@ public class IssueWorkflow implements Startable {
new PreviousStatusWas(Issue.STATUS_CONFIRMED),
new HasResolution(Issue.RESOLUTION_REMOVED, Issue.RESOLUTION_FIXED),
IsNotHotspotNorManualVulnerability.INSTANCE)
- .functions(UnsetCloseDate.INSTANCE)
+ .functions(RestoreResolutionFunction.INSTANCE, UnsetCloseDate.INSTANCE)
.automatic()
.build())
.transition(Transition.builder("automaticuncloseresolved")
@@ -277,7 +277,7 @@ public class IssueWorkflow implements Startable {
new PreviousStatusWas(Issue.STATUS_RESOLVED),
new HasResolution(Issue.RESOLUTION_REMOVED, Issue.RESOLUTION_FIXED),
IsNotHotspotNorManualVulnerability.INSTANCE)
- .functions(UnsetCloseDate.INSTANCE)
+ .functions(RestoreResolutionFunction.INSTANCE, UnsetCloseDate.INSTANCE)
.automatic()
.build())
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/PreviousStatusWas.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/PreviousStatusWas.java
index e3fec3623ae..80cf70cf8e7 100644
--- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/PreviousStatusWas.java
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/PreviousStatusWas.java
@@ -39,6 +39,7 @@ class PreviousStatusWas implements Condition {
Optional<String> lastPreviousStatus = defaultIssue.changes().stream()
// exclude current change (if any)
.filter(change -> change != defaultIssue.currentChange())
+ .filter(change -> change.creationDate() != null)
.sorted(Comparator.comparing(FieldDiffs::creationDate).reversed())
.map(change -> change.get("status"))
.filter(Objects::nonNull)
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/RestoreResolutionFunction.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/RestoreResolutionFunction.java
new file mode 100644
index 00000000000..77196c26cf6
--- /dev/null
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/workflow/RestoreResolutionFunction.java
@@ -0,0 +1,86 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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.workflow;
+
+import java.util.Comparator;
+import java.util.Objects;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.sonar.api.issue.Issue;
+import org.sonar.core.issue.DefaultIssue;
+import org.sonar.core.issue.FieldDiffs;
+
+enum RestoreResolutionFunction implements Function {
+ INSTANCE;
+
+ @Override
+ public void execute(Context context) {
+ DefaultIssue defaultIssue = (DefaultIssue) context.issue();
+ String previousResolution = defaultIssue.changes().stream()
+ // exclude current change (if any)
+ .filter(change -> change != defaultIssue.currentChange())
+ .filter(change -> change.creationDate() != null)
+ .sorted(Comparator.comparing(FieldDiffs::creationDate).reversed())
+ .map(this::parse)
+ .filter(Objects::nonNull)
+ .filter(StatusAndResolutionDiffs::hasResolution)
+ .findFirst()
+ .map(t -> t.newStatusClosed ? t.oldResolution : t.newResolution)
+ .orElse(null);
+ context.setResolution(previousResolution);
+ }
+
+ @CheckForNull
+ private StatusAndResolutionDiffs parse(FieldDiffs fieldDiffs) {
+ FieldDiffs.Diff status = fieldDiffs.get("status");
+ if (status == null) {
+ return null;
+ }
+ FieldDiffs.Diff resolution = fieldDiffs.get("resolution");
+ if (resolution == null) {
+ return new StatusAndResolutionDiffs(Issue.STATUS_CLOSED.equals(status.newValue()), null, null);
+ }
+ return new StatusAndResolutionDiffs(Issue.STATUS_CLOSED.equals(status.newValue()), (String) resolution.oldValue(), (String) resolution.newValue());
+ }
+
+ private static class StatusAndResolutionDiffs {
+ private final boolean newStatusClosed;
+ private final String oldResolution;
+ private final String newResolution;
+
+ private StatusAndResolutionDiffs(boolean newStatusClosed, @Nullable String oldResolution, @Nullable String newResolution) {
+ this.newStatusClosed = newStatusClosed;
+ this.oldResolution = emptyToNull(oldResolution);
+ this.newResolution = emptyToNull(newResolution);
+ }
+
+ private static String emptyToNull(@Nullable String str) {
+ if (str == null || str.isEmpty()) {
+ return null;
+ }
+ return str;
+ }
+
+ boolean hasResolution() {
+ return oldResolution != null || newResolution != null;
+ }
+ }
+
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/workflow/IssueWorkflowTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/workflow/IssueWorkflowTest.java
index c848d059559..3bc8b6283c8 100644
--- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/workflow/IssueWorkflowTest.java
+++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/workflow/IssueWorkflowTest.java
@@ -29,6 +29,7 @@ import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.List;
+import java.util.Random;
import javax.annotation.Nullable;
import org.apache.commons.lang.time.DateUtils;
import org.junit.Test;
@@ -41,6 +42,7 @@ import org.sonar.core.issue.FieldDiffs;
import org.sonar.core.issue.IssueChangeContext;
import org.sonar.server.issue.IssueFieldsSetter;
+import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.commons.lang.time.DateUtils.addDays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.fail;
@@ -57,8 +59,8 @@ import static org.sonar.api.issue.Issue.STATUS_RESOLVED;
@RunWith(DataProviderRunner.class)
public class IssueWorkflowTest {
- IssueFieldsSetter updater = new IssueFieldsSetter();
- IssueWorkflow workflow = new IssueWorkflow(new FunctionExecutor(updater), updater);
+ private IssueFieldsSetter updater = new IssueFieldsSetter();
+ private IssueWorkflow workflow = new IssueWorkflow(new FunctionExecutor(updater), updater);
@Test
public void init_state_machine() {
@@ -189,7 +191,7 @@ public class IssueWorkflowTest {
Date now = new Date();
addStatusChange(issue, addDays(now, -60), STATUS_OPEN, STATUS_CONFIRMED);
addStatusChange(issue, addDays(now, -10), STATUS_CONFIRMED, previousStatus);
- addStatusChange(issue, now, previousStatus, STATUS_CLOSED);
+ setStatusPreviousToClosed(issue, previousStatus);
return issue;
})
.toArray(DefaultIssue[]::new);
@@ -206,23 +208,81 @@ public class IssueWorkflowTest {
});
}
- @DataProvider
- public static Object[][] allStatusesLeadingToClosed() {
- return new Object[][] {
- {STATUS_OPEN},
- {STATUS_REOPENED},
- {STATUS_CONFIRMED},
- {STATUS_RESOLVED}
- };
+ @Test
+ @UseDataProvider("allResolutionsBeforeClosing")
+ public void automatically_reopen_closed_issue_to_previous_resolution_from_changelog(String resolutionBeforeClosed) {
+ String randomPreviousStatus = ALL_STATUSES_LEADING_TO_CLOSED[new Random().nextInt(ALL_STATUSES_LEADING_TO_CLOSED.length)];
+ DefaultIssue[] issues = Arrays.stream(SUPPORTED_RESOLUTIONS_FOR_UNCLOSING)
+ .map(resolution -> {
+ DefaultIssue issue = newClosedIssue(resolution);
+ addResolutionAndStatusChange(issue, new Date(), randomPreviousStatus, STATUS_CLOSED, resolutionBeforeClosed, resolution);
+ return issue;
+ })
+ .toArray(DefaultIssue[]::new);
+ Date now = new Date();
+ workflow.start();
+
+ Arrays.stream(issues).forEach(issue -> {
+ workflow.doAutomaticTransition(issue, IssueChangeContext.createScan(now));
+
+ assertThat(issue.status()).isEqualTo(randomPreviousStatus);
+ assertThat(issue.resolution()).isEqualTo(resolutionBeforeClosed);
+ assertThat(issue.updateDate()).isEqualTo(DateUtils.truncate(now, Calendar.SECOND));
+ assertThat(issue.closeDate()).isNull();
+ assertThat(issue.isChanged()).isTrue();
+ });
}
- private static final String[] SUPPORTED_RESOLUTIONS_FOR_UNCLOSING = new String[] {RESOLUTION_FIXED, RESOLUTION_REMOVED};
+ @Test
+ public void automatically_reopen_closed_issue_to_no_resolution_if_no_previous_one_changelog() {
+ String randomPreviousStatus = ALL_STATUSES_LEADING_TO_CLOSED[new Random().nextInt(ALL_STATUSES_LEADING_TO_CLOSED.length)];
+ DefaultIssue[] issues = Arrays.stream(SUPPORTED_RESOLUTIONS_FOR_UNCLOSING)
+ .map(resolution -> {
+ DefaultIssue issue = newClosedIssue(resolution);
+ setStatusPreviousToClosed(issue, randomPreviousStatus);
+ return issue;
+ })
+ .toArray(DefaultIssue[]::new);
+ Date now = new Date();
+ workflow.start();
- @DataProvider
- public static Object[][] supportedResolutionsForUnClosing() {
- return Arrays.stream(SUPPORTED_RESOLUTIONS_FOR_UNCLOSING)
- .map(t -> new Object[] {t})
- .toArray(Object[][]::new);
+ Arrays.stream(issues).forEach(issue -> {
+ workflow.doAutomaticTransition(issue, IssueChangeContext.createScan(now));
+
+ assertThat(issue.status()).isEqualTo(randomPreviousStatus);
+ assertThat(issue.resolution()).isNull();
+ assertThat(issue.updateDate()).isEqualTo(DateUtils.truncate(now, Calendar.SECOND));
+ assertThat(issue.closeDate()).isNull();
+ assertThat(issue.isChanged()).isTrue();
+ });
+ }
+
+ @Test
+ @UseDataProvider("allResolutionsBeforeClosing")
+ public void automatically_reopen_closed_issue_to_previous_resolution_of_closing_the_issue_if_most_recent_of_all_resolution_changes(String resolutionBeforeClosed) {
+ String randomPreviousStatus = ALL_STATUSES_LEADING_TO_CLOSED[new Random().nextInt(ALL_STATUSES_LEADING_TO_CLOSED.length)];
+ DefaultIssue[] issues = Arrays.stream(SUPPORTED_RESOLUTIONS_FOR_UNCLOSING)
+ .map(resolution -> {
+ DefaultIssue issue = newClosedIssue(resolution);
+ Date now = new Date();
+ addResolutionChange(issue, addDays(now, -60), null, RESOLUTION_FALSE_POSITIVE);
+ addResolutionChange(issue, addDays(now, -10), RESOLUTION_FALSE_POSITIVE, resolutionBeforeClosed);
+ addResolutionAndStatusChange(issue, now, randomPreviousStatus, STATUS_CLOSED, resolutionBeforeClosed, resolution);
+ return issue;
+ })
+ .toArray(DefaultIssue[]::new);
+ Date now = new Date();
+ workflow.start();
+
+ Arrays.stream(issues).forEach(issue -> {
+ workflow.doAutomaticTransition(issue, IssueChangeContext.createScan(now));
+
+ assertThat(issue.status()).isEqualTo(randomPreviousStatus);
+ assertThat(issue.resolution()).isEqualTo(resolutionBeforeClosed);
+ assertThat(issue.updateDate()).isEqualTo(DateUtils.truncate(now, Calendar.SECOND));
+ assertThat(issue.closeDate()).isNull();
+ assertThat(issue.isChanged()).isTrue();
+ });
}
@Test
@@ -285,15 +345,37 @@ public class IssueWorkflowTest {
});
}
+ private static final String[] ALL_STATUSES_LEADING_TO_CLOSED = new String[] {STATUS_OPEN, STATUS_REOPENED, STATUS_CONFIRMED, STATUS_RESOLVED};
+ private static final String[] ALL_RESOLUTIONS_BEFORE_CLOSING = new String[] {
+ null,
+ RESOLUTION_FIXED,
+ RESOLUTION_WONT_FIX,
+ RESOLUTION_FALSE_POSITIVE
+ };
+ private static final String[] SUPPORTED_RESOLUTIONS_FOR_UNCLOSING = new String[] {RESOLUTION_FIXED, RESOLUTION_REMOVED};
+
+ @DataProvider
+ public static Object[][] allStatusesLeadingToClosed() {
+ return Arrays.stream(ALL_STATUSES_LEADING_TO_CLOSED)
+ .map(t -> new Object[] {t})
+ .toArray(Object[][]::new);
+ }
+
+ @DataProvider
+ public static Object[][] allResolutionsBeforeClosing() {
+ return Arrays.stream(ALL_RESOLUTIONS_BEFORE_CLOSING)
+ .map(t -> new Object[] {t})
+ .toArray(Object[][]::new);
+ }
+
private static DefaultIssue newClosedIssue(String resolution) {
- DefaultIssue res = new DefaultIssue()
+ return new DefaultIssue()
.setKey("ABCDE")
.setRuleKey(RuleKey.of("js", "S001"))
.setResolution(resolution)
.setStatus(STATUS_CLOSED)
.setNew(false)
.setCloseDate(new Date(5_999_999L));
- return res;
}
private static void setStatusPreviousToClosed(DefaultIssue issue, String previousStatus) {
@@ -304,6 +386,30 @@ public class IssueWorkflowTest {
issue.addChange(new FieldDiffs().setCreationDate(date).setDiff("status", previousStatus, newStatus));
}
+ private void addResolutionChange(DefaultIssue issue, Date creationDate,
+ @Nullable String previousResolution, @Nullable String newResolution) {
+ checkArgument(previousResolution != null || newResolution != null, "At least one resolution must be non null");
+
+ FieldDiffs fieldDiffs = new FieldDiffs().setCreationDate(creationDate)
+ .setDiff("resolution", emptyIfNull(previousResolution), emptyIfNull(newResolution));
+ issue.addChange(fieldDiffs);
+ }
+
+ private void addResolutionAndStatusChange(DefaultIssue issue, Date creationDate,
+ String previousStatus, String newStatus,
+ @Nullable String previousResolution, @Nullable String newResolution) {
+ checkArgument(previousResolution != null || newResolution != null, "At least one resolution must be non null");
+
+ FieldDiffs fieldDiffs = new FieldDiffs().setCreationDate(creationDate)
+ .setDiff("status", previousStatus, newStatus)
+ .setDiff("resolution", emptyIfNull(previousResolution), emptyIfNull(newResolution));
+ issue.addChange(fieldDiffs);
+ }
+
+ private static String emptyIfNull(@Nullable String newResolution) {
+ return newResolution == null ? "" : newResolution;
+ }
+
@Test
public void close_open_dead_issue() {
workflow.start();
diff --git a/sonar-core/src/main/java/org/sonar/core/issue/FieldDiffs.java b/sonar-core/src/main/java/org/sonar/core/issue/FieldDiffs.java
index 47b6f194dd1..34e764aa298 100644
--- a/sonar-core/src/main/java/org/sonar/core/issue/FieldDiffs.java
+++ b/sonar-core/src/main/java/org/sonar/core/issue/FieldDiffs.java
@@ -183,11 +183,11 @@ public class FieldDiffs implements Serializable {
public String toString() {
// TODO escape , and | characters
StringBuilder sb = new StringBuilder();
+ if (oldValue != null) {
+ sb.append(oldValue.toString());
+ sb.append('|');
+ }
if (newValue != null) {
- if (oldValue != null) {
- sb.append(oldValue.toString());
- sb.append('|');
- }
sb.append(newValue.toString());
}
return sb.toString();
diff --git a/sonar-core/src/test/java/org/sonar/core/issue/FieldDiffsTest.java b/sonar-core/src/test/java/org/sonar/core/issue/FieldDiffsTest.java
index 962dcae300e..b4bab8d0491 100644
--- a/sonar-core/src/test/java/org/sonar/core/issue/FieldDiffsTest.java
+++ b/sonar-core/src/test/java/org/sonar/core/issue/FieldDiffsTest.java
@@ -104,13 +104,13 @@ public class FieldDiffsTest {
diffs.setDiff("severity", null, "INFO");
diffs.setDiff("assignee", "user1", null);
- assertThat(diffs.toString()).isEqualTo("severity=INFO,assignee=");
+ assertThat(diffs.toString()).isEqualTo("severity=INFO,assignee=user1|");
}
@Test
public void test_parse() {
- diffs = FieldDiffs.parse("severity=BLOCKER|INFO,resolution=OPEN|FIXED,donut=|new,acme=old|");
- assertThat(diffs.diffs()).hasSize(4);
+ diffs = FieldDiffs.parse("severity=BLOCKER|INFO,resolution=OPEN|FIXED,donut=|new,gambas=miam,acme=old|");
+ assertThat(diffs.diffs()).hasSize(5);
FieldDiffs.Diff diff = diffs.diffs().get("severity");
assertThat(diff.oldValue()).isEqualTo("BLOCKER");
@@ -124,6 +124,10 @@ public class FieldDiffsTest {
assertThat(diff.oldValue()).isEqualTo("");
assertThat(diff.newValue()).isEqualTo("new");
+ diff = diffs.diffs().get("gambas");
+ assertThat(diff.oldValue()).isEqualTo("");
+ assertThat(diff.newValue()).isEqualTo("miam");
+
diff = diffs.diffs().get("acme");
assertThat(diff.oldValue()).isEqualTo("old");
assertThat(diff.newValue()).isEqualTo("");