]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-6078 Aggregate technical debt on issues search WS 391/head
authorJean-Baptiste Lievremont <jean-baptiste.lievremont@sonarsource.com>
Mon, 15 Jun 2015 15:31:57 +0000 (17:31 +0200)
committerJean-Baptiste Lievremont <jean-baptiste.lievremont@sonarsource.com>
Thu, 25 Jun 2015 09:56:29 +0000 (11:56 +0200)
server/sonar-server/src/main/java/org/sonar/server/es/Facets.java
server/sonar-server/src/main/java/org/sonar/server/issue/IssueQuery.java
server/sonar-server/src/main/java/org/sonar/server/issue/IssueQueryService.java
server/sonar-server/src/main/java/org/sonar/server/issue/filter/IssueFilterParameters.java
server/sonar-server/src/main/java/org/sonar/server/issue/index/IssueIndex.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SearchAction.java
server/sonar-server/src/main/java/org/sonar/server/search/StickyFacetBuilder.java
server/sonar-server/src/test/java/org/sonar/server/issue/index/IssueIndexDebtTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SearchActionMediumTest.java
server/sonar-server/src/test/resources/org/sonar/server/issue/ws/SearchActionMediumTest/display_facets_debt.json [new file with mode: 0644]

index a22131e18f33e9c07762b9f910255ef2798e7515..9bc8519608e8123a10116c6ba7249d8ab2edb8da 100644 (file)
  */
 package org.sonar.server.es;
 
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.CheckForNull;
 import org.apache.commons.lang.builder.ReflectionToStringBuilder;
 import org.apache.commons.lang.builder.ToStringStyle;
 import org.elasticsearch.action.search.SearchResponse;
@@ -27,15 +32,12 @@ import org.elasticsearch.search.aggregations.HasAggregations;
 import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogram;
 import org.elasticsearch.search.aggregations.bucket.missing.Missing;
 import org.elasticsearch.search.aggregations.bucket.terms.Terms;
-
-import javax.annotation.CheckForNull;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
+import org.elasticsearch.search.aggregations.metrics.sum.Sum;
 
 public class Facets {
 
+  public static final String TOTAL = "total";
+
   private final Map<String, LinkedHashMap<String, Long>> facetsByName = new LinkedHashMap<>();
 
   public Facets(SearchResponse response) {
@@ -59,6 +61,8 @@ public class Facets {
       processSubAggregations((HasAggregations) aggregation);
     } else if (DateHistogram.class.isAssignableFrom(aggregation.getClass())) {
       processDateHistogram((DateHistogram) aggregation);
+    } else if (Sum.class.isAssignableFrom(aggregation.getClass())) {
+      processSum((Sum) aggregation);
     } else {
       throw new IllegalArgumentException("Aggregation type not supported yet: " + aggregation.getClass());
     }
@@ -67,7 +71,12 @@ public class Facets {
   private void processMissingAggregation(Missing aggregation) {
     long docCount = aggregation.getDocCount();
     if (docCount > 0L) {
-      getOrCreateFacet(aggregation.getName().replace("_missing", "")).put("", docCount);
+      LinkedHashMap<String, Long> facet = getOrCreateFacet(aggregation.getName().replace("_missing", ""));
+      if (aggregation.getAggregations().getAsMap().containsKey("debt")) {
+        facet.put("", Math.round(((Sum) aggregation.getAggregations().get("debt")).getValue()));
+      } else {
+        facet.put("", docCount);
+      }
     }
   }
 
@@ -80,7 +89,11 @@ public class Facets {
     facetName = facetName.replace("_selected", "");
     LinkedHashMap<String, Long> facet = getOrCreateFacet(facetName);
     for (Terms.Bucket value : aggregation.getBuckets()) {
-      facet.put(value.getKey(), value.getDocCount());
+      if (value.getAggregations().getAsMap().containsKey("debt")) {
+        facet.put(value.getKey(), Math.round(((Sum) value.getAggregations().get("debt")).getValue()));
+      } else {
+        facet.put(value.getKey(), value.getDocCount());
+      }
     }
   }
 
@@ -93,10 +106,18 @@ public class Facets {
   private void processDateHistogram(DateHistogram aggregation) {
     LinkedHashMap<String, Long> facet = getOrCreateFacet(aggregation.getName());
     for (DateHistogram.Bucket value : aggregation.getBuckets()) {
-      facet.put(value.getKeyAsText().toString(), value.getDocCount());
+      if (value.getAggregations().getAsMap().containsKey("debt")) {
+        facet.put(value.getKey(), Math.round(((Sum) value.getAggregations().get("debt")).getValue()));
+      } else {
+        facet.put(value.getKey(), value.getDocCount());
+      }
     }
   }
 
+  private void processSum(Sum aggregation) {
+    getOrCreateFacet(aggregation.getName()).put(TOTAL, Math.round(aggregation.getValue()));
+  }
+
   public boolean contains(String facetName) {
     return facetsByName.containsKey(facetName);
   }
index bcb2c9654af29fce75b414b06006050187528908..ee4a1ce1d1748ddb9865b3da6e13aa4a178fd326 100644 (file)
@@ -83,6 +83,7 @@ public class IssueQuery {
   private final Date createdBefore;
   private final String sort;
   private final Boolean asc;
+  private final String facetMode;
 
   private final String userLogin;
   private final Set<String> userGroups;
@@ -121,6 +122,7 @@ public class IssueQuery {
     this.userLogin = builder.userLogin;
     this.userGroups = builder.userGroups;
     this.checkAuthorization = builder.checkAuthorization;
+    this.facetMode = builder.facetMode;
   }
 
   public Collection<String> issueKeys() {
@@ -269,6 +271,10 @@ public class IssueQuery {
     return checkAuthorization;
   }
 
+  public String facetMode() {
+    return facetMode;
+  }
+
   @Override
   public String toString() {
     return ReflectionToStringBuilder.toString(this);
@@ -311,6 +317,7 @@ public class IssueQuery {
     private String userLogin;
     private Set<String> userGroups;
     private boolean checkAuthorization = true;
+    private String facetMode;
 
     private Builder(UserSession userSession) {
       this.userLogin = userSession.getLogin();
@@ -518,6 +525,10 @@ public class IssueQuery {
       return this;
     }
 
+    public Builder facetMode(String facetMode) {
+      this.facetMode = facetMode;
+      return this;
+    }
   }
 
   private static <T> Collection<T> defaultCollection(@Nullable Collection<T> c) {
index 38f40ab43db11a30e58c2fc1ad43d4c7d9accf03..134a9414a4bf40b3bd5d13041dfddf689d99fc05 100644 (file)
@@ -132,6 +132,12 @@ public class IssueQueryService {
         builder.sort(sort);
         builder.asc(RubyUtils.toBoolean(params.get(IssueFilterParameters.ASC)));
       }
+      String facetMode = (String) params.get(IssueFilterParameters.FACET_MODE);
+      if (!Strings.isNullOrEmpty(facetMode)) {
+        builder.facetMode(facetMode);
+      } else {
+        builder.facetMode(IssueFilterParameters.FACET_MODE_COUNT);
+      }
       return builder.build();
 
     } finally {
@@ -168,7 +174,8 @@ public class IssueQueryService {
         .planned(request.paramAsBoolean(IssueFilterParameters.PLANNED))
         .createdAt(request.paramAsDateTime(IssueFilterParameters.CREATED_AT))
         .createdAfter(buildCreatedAfter(request.paramAsDateTime(IssueFilterParameters.CREATED_AFTER), request.param(IssueFilterParameters.CREATED_IN_LAST)))
-        .createdBefore(request.paramAsDateTime(IssueFilterParameters.CREATED_BEFORE));
+        .createdBefore(request.paramAsDateTime(IssueFilterParameters.CREATED_BEFORE))
+        .facetMode(request.mandatoryParam(IssueFilterParameters.FACET_MODE));
 
       Set<String> allComponentUuids = Sets.newHashSet();
       boolean effectiveOnComponentOnly = mergeDeprecatedComponentParameters(session,
index 96b5589eb160874572d24a60249aef59459c2eba..3f1b221d4ec93382c1f6299b89257d89457195e5 100644 (file)
@@ -68,11 +68,15 @@ public class IssueFilterParameters {
   public static final String PAGE_INDEX = "pageIndex";
   public static final String SORT = "sort";
   public static final String ASC = "asc";
+  public static final String FACET_MODE = "facetMode";
+
+  public static final String FACET_MODE_COUNT = "count";
+  public static final String FACET_MODE_DEBT = "debt";
 
   public static final String FACET_ASSIGNED_TO_ME = "assigned_to_me";
 
   public static final List<String> ALL = ImmutableList.of(ISSUES, SEVERITIES, STATUSES, RESOLUTIONS, RESOLVED, COMPONENTS, COMPONENT_ROOTS, RULES, ACTION_PLANS, REPORTERS, TAGS,
-    ASSIGNEES, LANGUAGES, ASSIGNED, PLANNED, HIDE_RULES, CREATED_AT, CREATED_AFTER, CREATED_BEFORE, CREATED_IN_LAST, COMPONENT_UUIDS, COMPONENT_ROOT_UUIDS,
+    ASSIGNEES, LANGUAGES, ASSIGNED, PLANNED, HIDE_RULES, CREATED_AT, CREATED_AFTER, CREATED_BEFORE, CREATED_IN_LAST, COMPONENT_UUIDS, COMPONENT_ROOT_UUIDS, FACET_MODE,
     PROJECTS, PROJECT_UUIDS, PROJECT_KEYS, COMPONENT_KEYS, MODULE_UUIDS, DIRECTORIES, FILE_UUIDS, AUTHORS, HIDE_COMMENTS, PAGE_SIZE, PAGE_INDEX, SORT, ASC);
 
   public static final List<String> ALL_WITHOUT_PAGINATION = newArrayList(Iterables.filter(ALL, new Predicate<String>() {
index b73dc7a7a0b5645dad287b65fd9d226d6ced9ec3..98650655de5b6b1fe1bdfd6b63831b1ff880ceb1 100644 (file)
@@ -57,8 +57,10 @@ import org.elasticsearch.search.aggregations.bucket.global.Global;
 import org.elasticsearch.search.aggregations.bucket.global.GlobalBuilder;
 import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogram;
 import org.elasticsearch.search.aggregations.bucket.terms.Terms;
+import org.elasticsearch.search.aggregations.bucket.terms.Terms.Order;
 import org.elasticsearch.search.aggregations.bucket.terms.TermsBuilder;
 import org.elasticsearch.search.aggregations.metrics.min.Min;
+import org.elasticsearch.search.aggregations.metrics.sum.SumBuilder;
 import org.joda.time.Duration;
 import org.sonar.api.issue.Issue;
 import org.sonar.api.resources.Scopes;
@@ -90,6 +92,7 @@ import static com.google.common.collect.Lists.newArrayList;
  */
 public class IssueIndex extends BaseIndex {
 
+
   private static final String SUBSTRING_MATCH_REGEXP = ".*%s.*";
 
   public static final List<String> SUPPORTED_FACETS = ImmutableList.of(
@@ -116,6 +119,10 @@ public class IssueIndex extends BaseIndex {
 
   private static final String IS_ASSIGNED_FILTER = "__isAssigned";
 
+  public static final String DEBT_AGGREGATION_NAME = "debt";
+  private static final SumBuilder DEBT_AGGREGATION = AggregationBuilders.sum(DEBT_AGGREGATION_NAME).field(IssueIndexDefinition.FIELD_ISSUE_DEBT);
+  private static final Order DEBT_AGGREGATION_ORDER = Order.aggregation(DEBT_AGGREGATION_NAME, false);
+
   private static final int DEFAULT_FACET_SIZE = 15;
   private static final Duration TWENTY_DAYS = Duration.standardDays(20L);
   private static final Duration TWENTY_WEEKS = Duration.standardDays(20L * 7L);
@@ -373,7 +380,7 @@ public class IssueIndex extends BaseIndex {
 
   private void configureStickyFacets(IssueQuery query, SearchOptions options, Map<String, FilterBuilder> filters, QueryBuilder esQuery, SearchRequestBuilder esSearch) {
     if (!options.getFacets().isEmpty()) {
-      StickyFacetBuilder stickyFacetBuilder = new StickyFacetBuilder(esQuery, filters);
+      StickyFacetBuilder stickyFacetBuilder = newStickyFacetBuilder(query, filters, esQuery);
       // Execute Term aggregations
       addSimpleStickyFacetIfNeeded(options, stickyFacetBuilder, esSearch,
         IssueFilterParameters.SEVERITIES, IssueIndexDefinition.FIELD_ISSUE_SEVERITY, Severity.ALL.toArray());
@@ -402,7 +409,7 @@ public class IssueIndex extends BaseIndex {
       }
 
       if (options.getFacets().contains(IssueFilterParameters.RESOLUTIONS)) {
-        esSearch.addAggregation(createResolutionFacet(filters, esQuery));
+        esSearch.addAggregation(createResolutionFacet(query, filters, esQuery));
       }
       if (options.getFacets().contains(IssueFilterParameters.ASSIGNEES)) {
         esSearch.addAggregation(createAssigneesFacet(query, filters, esQuery));
@@ -415,6 +422,20 @@ public class IssueIndex extends BaseIndex {
         esSearch.addAggregation(getCreatedAtFacet(query, filters, esQuery));
       }
     }
+
+    if (IssueFilterParameters.FACET_MODE_DEBT.equals(query.facetMode())) {
+      esSearch.addAggregation(DEBT_AGGREGATION);
+    }
+  }
+
+  private StickyFacetBuilder newStickyFacetBuilder(IssueQuery query, Map<String, FilterBuilder> filters, QueryBuilder esQuery) {
+    StickyFacetBuilder stickyFacetBuilder;
+    if (IssueFilterParameters.FACET_MODE_DEBT.equals(query.facetMode())) {
+      stickyFacetBuilder = new StickyFacetBuilder(esQuery, filters, DEBT_AGGREGATION, DEBT_AGGREGATION_ORDER);
+    } else {
+      stickyFacetBuilder = new StickyFacetBuilder(esQuery, filters);
+    }
+    return stickyFacetBuilder;
   }
 
   private void addSimpleStickyFacetIfNeeded(SearchOptions options, StickyFacetBuilder stickyFacetBuilder, SearchRequestBuilder esSearch,
@@ -424,6 +445,13 @@ public class IssueIndex extends BaseIndex {
     }
   }
 
+  private AggregationBuilder addDebtAggregationIfNeeded(IssueQuery query, AggregationBuilder aggregation) {
+    if (IssueFilterParameters.FACET_MODE_DEBT.equals(query.facetMode())) {
+      aggregation.subAggregation(DEBT_AGGREGATION);
+    }
+    return aggregation;
+  }
+
   private AggregationBuilder getCreatedAtFacet(IssueQuery query, Map<String, FilterBuilder> filters, QueryBuilder esQuery) {
     long now = system.now();
 
@@ -445,7 +473,7 @@ public class IssueIndex extends BaseIndex {
       bucketSize = DateHistogram.Interval.MONTH;
     }
 
-    return AggregationBuilders.dateHistogram(IssueFilterParameters.CREATED_AT)
+    AggregationBuilder dateHistogram = AggregationBuilders.dateHistogram(IssueFilterParameters.CREATED_AT)
       .field(IssueIndexDefinition.FIELD_ISSUE_FUNC_CREATED_AT)
       .interval(bucketSize)
       .minDocCount(0L)
@@ -453,6 +481,8 @@ public class IssueIndex extends BaseIndex {
       .preZone(timeZoneString)
       .postZone(timeZoneString)
       .extendedBounds(startTime, endTime);
+    dateHistogram = addDebtAggregationIfNeeded(query, dateHistogram);
+    return dateHistogram;
   }
 
   private long getMinCreatedAt(Map<String, FilterBuilder> filters, QueryBuilder esQuery) {
@@ -490,7 +520,7 @@ public class IssueIndex extends BaseIndex {
     Map<String, FilterBuilder> assigneeFilters = Maps.newHashMap(filters);
     assigneeFilters.remove(IS_ASSIGNED_FILTER);
     assigneeFilters.remove(fieldName);
-    StickyFacetBuilder assigneeFacetBuilder = new StickyFacetBuilder(queryBuilder, assigneeFilters);
+    StickyFacetBuilder assigneeFacetBuilder = newStickyFacetBuilder(query, assigneeFilters, queryBuilder);
     BoolFilterBuilder facetFilter = assigneeFacetBuilder.getStickyFacetFilter(fieldName);
     FilterAggregationBuilder facetTopAggregation = assigneeFacetBuilder.buildTopFacetAggregation(fieldName, facetName, facetFilter, DEFAULT_FACET_SIZE);
 
@@ -501,9 +531,9 @@ public class IssueIndex extends BaseIndex {
 
     // Add missing facet for unassigned issues
     facetTopAggregation.subAggregation(
-      AggregationBuilders
+      addDebtAggregationIfNeeded(query, AggregationBuilders
         .missing(facetName + FACET_SUFFIX_MISSING)
-        .field(fieldName)
+        .field(fieldName))
       );
 
     return AggregationBuilders
@@ -531,20 +561,20 @@ public class IssueIndex extends BaseIndex {
     String facetName = IssueFilterParameters.FACET_ASSIGNED_TO_ME;
 
     // Same as in super.stickyFacetBuilder
-    StickyFacetBuilder assignedToMeFacetBuilder = new StickyFacetBuilder(queryBuilder, filters);
+    StickyFacetBuilder assignedToMeFacetBuilder = newStickyFacetBuilder(query, filters, queryBuilder);
     BoolFilterBuilder facetFilter = assignedToMeFacetBuilder.getStickyFacetFilter(IS_ASSIGNED_FILTER, fieldName);
 
     FilterAggregationBuilder facetTopAggregation = AggregationBuilders
       .filter(facetName + "__filter")
       .filter(facetFilter)
-      .subAggregation(AggregationBuilders.terms(facetName + "__terms").field(fieldName).include(login));
+      .subAggregation(addDebtAggregationIfNeeded(query, AggregationBuilders.terms(facetName + "__terms").field(fieldName).include(login)));
 
     builder.addAggregation(
       AggregationBuilders.global(facetName)
         .subAggregation(facetTopAggregation));
   }
 
-  private AggregationBuilder createResolutionFacet(Map<String, FilterBuilder> filters, QueryBuilder esQuery) {
+  private AggregationBuilder createResolutionFacet(IssueQuery query, Map<String, FilterBuilder> filters, QueryBuilder esQuery) {
     String fieldName = IssueIndexDefinition.FIELD_ISSUE_RESOLUTION;
     String facetName = IssueFilterParameters.RESOLUTIONS;
 
@@ -552,16 +582,16 @@ public class IssueIndex extends BaseIndex {
     Map<String, FilterBuilder> resolutionFilters = Maps.newHashMap(filters);
     resolutionFilters.remove("__isResolved");
     resolutionFilters.remove(fieldName);
-    StickyFacetBuilder assigneeFacetBuilder = new StickyFacetBuilder(esQuery, resolutionFilters);
+    StickyFacetBuilder assigneeFacetBuilder = newStickyFacetBuilder(query, resolutionFilters, esQuery);
     BoolFilterBuilder facetFilter = assigneeFacetBuilder.getStickyFacetFilter(fieldName);
     FilterAggregationBuilder facetTopAggregation = assigneeFacetBuilder.buildTopFacetAggregation(fieldName, facetName, facetFilter, DEFAULT_FACET_SIZE);
     facetTopAggregation = assigneeFacetBuilder.addSelectedItemsToFacet(fieldName, facetName, facetTopAggregation, Issue.RESOLUTIONS.toArray());
 
     // Add missing facet for unresolved issues
     facetTopAggregation.subAggregation(
-      AggregationBuilders
+      addDebtAggregationIfNeeded(query, AggregationBuilders
         .missing(facetName + FACET_SUFFIX_MISSING)
-        .field(fieldName)
+        .field(fieldName))
       );
 
     return AggregationBuilders
@@ -577,16 +607,16 @@ public class IssueIndex extends BaseIndex {
     Map<String, FilterBuilder> actionPlanFilters = Maps.newHashMap(filters);
     actionPlanFilters.remove("__isPlanned");
     actionPlanFilters.remove(fieldName);
-    StickyFacetBuilder actionPlanFacetBuilder = new StickyFacetBuilder(esQuery, actionPlanFilters);
+    StickyFacetBuilder actionPlanFacetBuilder = newStickyFacetBuilder(query, actionPlanFilters, esQuery);
     BoolFilterBuilder facetFilter = actionPlanFacetBuilder.getStickyFacetFilter(fieldName);
     FilterAggregationBuilder facetTopAggregation = actionPlanFacetBuilder.buildTopFacetAggregation(fieldName, facetName, facetFilter, DEFAULT_FACET_SIZE);
     facetTopAggregation = actionPlanFacetBuilder.addSelectedItemsToFacet(fieldName, facetName, facetTopAggregation, query.actionPlans().toArray());
 
     // Add missing facet for unresolved issues
     facetTopAggregation.subAggregation(
-      AggregationBuilders
+      addDebtAggregationIfNeeded(query, AggregationBuilders
         .missing(facetName + FACET_SUFFIX_MISSING)
-        .field(fieldName)
+        .field(fieldName))
       );
 
     return AggregationBuilders
index 512de33d3ef34658842e64856b36f872b9e509c2..3cc0cbe5a7f39d4e20fb385ba7cdba9ff4431903 100644 (file)
@@ -55,6 +55,7 @@ import org.sonar.core.component.ComponentDto;
 import org.sonar.core.persistence.DbSession;
 import org.sonar.server.component.ws.ComponentJsonWriter;
 import org.sonar.server.db.DbClient;
+import org.sonar.server.es.Facets;
 import org.sonar.server.es.SearchOptions;
 import org.sonar.server.es.SearchResult;
 import org.sonar.server.issue.IssueQuery;
@@ -126,6 +127,10 @@ public class SearchAction implements IssuesWsAction {
     action.createParam(WebService.Param.FACETS)
       .setDescription("Comma-separated list of the facets to be computed. No facet is computed by default.")
       .setPossibleValues(IssueIndex.SUPPORTED_FACETS);
+    action.createParam(IssueFilterParameters.FACET_MODE)
+      .setDefaultValue(IssueFilterParameters.FACET_MODE_COUNT)
+      .setDescription("Choose the returned value for facet items, either count of issues or sum of debt.")
+      .setPossibleValues(IssueFilterParameters.FACET_MODE_COUNT, IssueFilterParameters.FACET_MODE_DEBT);
     action.addSortParams(IssueQuery.SORTS, null, true);
     action.addFieldsParam(IssueJsonWriter.SELECTABLE_FIELDS);
 
@@ -286,6 +291,10 @@ public class SearchAction implements IssuesWsAction {
   }
 
   private void writeResponse(Request request, SearchResult<IssueDoc> result, JsonWriter json) {
+    if (result.getFacets().contains(IssueIndex.DEBT_AGGREGATION_NAME)) {
+      json.prop("debtTotal", result.getFacets().get(IssueIndex.DEBT_AGGREGATION_NAME).get(Facets.TOTAL));
+    }
+
     List<String> issueKeys = newArrayList();
     Set<RuleKey> ruleKeys = newHashSet();
     Set<String> projectUuids = newHashSet();
index 589536f14844988faa4ce992ee31275f3a358aa7..5bbf441c7a4e36caf3e1556d61ce9bd2d96dffc5 100644 (file)
 package org.sonar.server.search;
 
 import com.google.common.base.Joiner;
+import java.util.Map;
+import javax.annotation.Nullable;
 import org.apache.commons.lang.ArrayUtils;
 import org.elasticsearch.index.query.BoolFilterBuilder;
 import org.elasticsearch.index.query.FilterBuilder;
 import org.elasticsearch.index.query.FilterBuilders;
 import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.aggregations.AbstractAggregationBuilder;
 import org.elasticsearch.search.aggregations.AggregationBuilder;
 import org.elasticsearch.search.aggregations.AggregationBuilders;
 import org.elasticsearch.search.aggregations.bucket.filter.FilterAggregationBuilder;
 import org.elasticsearch.search.aggregations.bucket.terms.Terms;
-
-import java.util.Map;
+import org.elasticsearch.search.aggregations.bucket.terms.Terms.Order;
+import org.elasticsearch.search.aggregations.bucket.terms.TermsBuilder;
 
 public class StickyFacetBuilder {
 
   private static final int FACET_DEFAULT_MIN_DOC_COUNT = 1;
   private static final int FACET_DEFAULT_SIZE = 10;
+  private static final Order FACET_DEFAULT_ORDER = Terms.Order.count(false);
 
   private final QueryBuilder query;
   private final Map<String, FilterBuilder> filters;
+  private final AbstractAggregationBuilder subAggregation;
+  private final Order order;
 
   public StickyFacetBuilder(QueryBuilder query, Map<String, FilterBuilder> filters) {
+    this(query, filters, null, FACET_DEFAULT_ORDER);
+  }
+
+  public StickyFacetBuilder(QueryBuilder query, Map<String, FilterBuilder> filters, @Nullable AbstractAggregationBuilder subAggregation, @Nullable Order order) {
     this.query = query;
     this.filters = filters;
+    this.subAggregation = subAggregation;
+    this.order = order;
   }
 
   public QueryBuilder query() {
@@ -78,23 +90,31 @@ public class StickyFacetBuilder {
   }
 
   public FilterAggregationBuilder buildTopFacetAggregation(String fieldName, String facetName, BoolFilterBuilder facetFilter, int size) {
+    TermsBuilder termsAggregation = AggregationBuilders.terms(facetName)
+      .field(fieldName)
+      .order(order)
+      // .order(Terms.Order.aggregation("debt", false))
+      .size(size)
+      .minDocCount(FACET_DEFAULT_MIN_DOC_COUNT);
+    if (subAggregation != null) {
+      termsAggregation = termsAggregation.subAggregation(subAggregation);
+    }
     return AggregationBuilders
       .filter(facetName + "_filter")
       .filter(facetFilter)
-      .subAggregation(
-        AggregationBuilders.terms(facetName)
-          .field(fieldName)
-          .order(Terms.Order.count(false))
-          .size(size)
-          .minDocCount(FACET_DEFAULT_MIN_DOC_COUNT));
+      .subAggregation(termsAggregation);
   }
 
   public FilterAggregationBuilder addSelectedItemsToFacet(String fieldName, String facetName, FilterAggregationBuilder facetTopAggregation, Object... selected) {
     if (selected.length > 0) {
+      TermsBuilder selectedTerms = AggregationBuilders.terms(facetName + "_selected")
+        .field(fieldName)
+        .include(Joiner.on('|').join(selected));
+      if (subAggregation != null) {
+        selectedTerms = selectedTerms.subAggregation(subAggregation);
+      }
       facetTopAggregation.subAggregation(
-        AggregationBuilders.terms(facetName + "_selected")
-          .field(fieldName)
-          .include(Joiner.on('|').join(selected)));
+        selectedTerms);
     }
     return facetTopAggregation;
   }
diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/index/IssueIndexDebtTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/index/IssueIndexDebtTest.java
new file mode 100644 (file)
index 0000000..d196271
--- /dev/null
@@ -0,0 +1,293 @@
+/*
+ * SonarQube, open source software quality management tool.
+ * Copyright (C) 2008-2014 SonarSource
+ * mailto:contact AT sonarsource DOT com
+ *
+ * SonarQube 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.
+ *
+ * SonarQube 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.index;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.TimeZone;
+import javax.annotation.Nullable;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.config.Settings;
+import org.sonar.api.issue.Issue;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.api.rule.Severity;
+import org.sonar.api.security.DefaultGroups;
+import org.sonar.api.utils.DateUtils;
+import org.sonar.api.utils.System2;
+import org.sonar.core.component.ComponentDto;
+import org.sonar.server.component.ComponentTesting;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.es.SearchOptions;
+import org.sonar.server.es.SearchResult;
+import org.sonar.server.issue.IssueQuery;
+import org.sonar.server.issue.IssueQuery.Builder;
+import org.sonar.server.issue.IssueTesting;
+import org.sonar.server.issue.filter.IssueFilterParameters;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.view.index.ViewIndexDefinition;
+import org.sonar.server.view.index.ViewIndexer;
+
+import static com.google.common.collect.Lists.newArrayList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class IssueIndexDebtTest {
+
+  @ClassRule
+  public static EsTester tester = new EsTester().addDefinitions(new IssueIndexDefinition(new Settings()), new ViewIndexDefinition(new Settings()));
+  @Rule
+  public UserSessionRule userSessionRule = UserSessionRule.standalone();
+
+  IssueIndex index;
+
+  IssueIndexer issueIndexer;
+  IssueAuthorizationIndexer issueAuthorizationIndexer;
+  ViewIndexer viewIndexer;
+
+  @Before
+  public void setUp() {
+    tester.truncateIndices();
+    issueIndexer = new IssueIndexer(null, tester.client());
+    issueAuthorizationIndexer = new IssueAuthorizationIndexer(null, tester.client());
+    viewIndexer = new ViewIndexer(null, tester.client());
+    System2 system = mock(System2.class);
+    when(system.getDefaultTimeZone()).thenReturn(TimeZone.getTimeZone("+01:00"));
+    when(system.now()).thenReturn(System.currentTimeMillis());
+
+    index = new IssueIndex(tester.client(), system, userSessionRule);
+
+  }
+
+  @Test
+  public void facets_on_projects() {
+    ComponentDto project = ComponentTesting.newProjectDto("ABCD");
+    ComponentDto project2 = ComponentTesting.newProjectDto("EFGH");
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", ComponentTesting.newFileDto(project)),
+      IssueTesting.newDoc("ISSUE2", ComponentTesting.newFileDto(project)),
+      IssueTesting.newDoc("ISSUE3", ComponentTesting.newFileDto(project2)));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("projectUuids")));
+    assertThat(result.getFacets().getNames()).containsOnly("projectUuids", "debt");
+    assertThat(result.getFacets().get("projectUuids")).containsOnly(entry("ABCD", 20L), entry("EFGH", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 30L));
+  }
+
+  @Test
+  public void facets_on_components() {
+    ComponentDto project = ComponentTesting.newProjectDto("A");
+    ComponentDto file1 = ComponentTesting.newFileDto(project, "ABCD");
+    ComponentDto file2 = ComponentTesting.newFileDto(project, "BCDE");
+    ComponentDto file3 = ComponentTesting.newFileDto(project, "CDEF");
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", project),
+      IssueTesting.newDoc("ISSUE2", file1),
+      IssueTesting.newDoc("ISSUE3", file2),
+      IssueTesting.newDoc("ISSUE4", file2),
+      IssueTesting.newDoc("ISSUE5", file3));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("fileUuids")));
+    assertThat(result.getFacets().getNames()).containsOnly("fileUuids", "debt");
+    assertThat(result.getFacets().get("fileUuids"))
+      .containsOnly(entry("A", 10L), entry("ABCD", 10L), entry("BCDE", 20L), entry("CDEF", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 50L));
+  }
+
+  @Test
+  public void facets_on_directories() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file1 = ComponentTesting.newFileDto(project).setPath("src/main/xoo/F1.xoo");
+    ComponentDto file2 = ComponentTesting.newFileDto(project).setPath("F2.xoo");
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file1).setDirectoryPath("/src/main/xoo"),
+      IssueTesting.newDoc("ISSUE2", file2).setDirectoryPath("/"));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("directories")));
+    assertThat(result.getFacets().getNames()).containsOnly("directories", "debt");
+    assertThat(result.getFacets().get("directories")).containsOnly(entry("/src/main/xoo", 10L), entry("/", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 20L));
+  }
+
+  @Test
+  public void facets_on_severities() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file).setSeverity(Severity.INFO),
+      IssueTesting.newDoc("ISSUE2", file).setSeverity(Severity.INFO),
+      IssueTesting.newDoc("ISSUE3", file).setSeverity(Severity.MAJOR));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("severities")));
+    assertThat(result.getFacets().getNames()).containsOnly("severities", "debt");
+    assertThat(result.getFacets().get("severities")).containsOnly(entry("INFO", 20L), entry("MAJOR", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 30L));
+  }
+
+  @Test
+  public void facets_on_statuses() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file).setStatus(Issue.STATUS_CLOSED),
+      IssueTesting.newDoc("ISSUE2", file).setStatus(Issue.STATUS_CLOSED),
+      IssueTesting.newDoc("ISSUE3", file).setStatus(Issue.STATUS_OPEN));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("statuses")));
+    assertThat(result.getFacets().getNames()).containsOnly("statuses", "debt");
+    assertThat(result.getFacets().get("statuses")).containsOnly(entry("CLOSED", 20L), entry("OPEN", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 30L));
+  }
+
+  @Test
+  public void facets_on_resolutions() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file).setResolution(Issue.RESOLUTION_FALSE_POSITIVE),
+      IssueTesting.newDoc("ISSUE2", file).setResolution(Issue.RESOLUTION_FALSE_POSITIVE),
+      IssueTesting.newDoc("ISSUE3", file).setResolution(Issue.RESOLUTION_FIXED));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("resolutions")));
+    assertThat(result.getFacets().getNames()).containsOnly("resolutions", "debt");
+    assertThat(result.getFacets().get("resolutions")).containsOnly(entry("FALSE-POSITIVE", 20L), entry("FIXED", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 30L));
+  }
+
+  @Test
+  public void facets_on_action_plans() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file).setActionPlanKey("plan1"),
+      IssueTesting.newDoc("ISSUE2", file).setActionPlanKey("plan2"));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("actionPlans")));
+    assertThat(result.getFacets().getNames()).containsOnly("actionPlans", "debt");
+    assertThat(result.getFacets().get("actionPlans")).containsOnly(entry("plan1", 10L), entry("plan2", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 20L));
+  }
+
+  @Test
+  public void facets_on_languages() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+    RuleKey ruleKey = RuleKey.of("repo", "X1");
+
+    indexIssues(IssueTesting.newDoc("ISSUE1", file).setRuleKey(ruleKey.toString()).setLanguage("xoo"));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("languages")));
+    assertThat(result.getFacets().getNames()).containsOnly("languages", "debt");
+    assertThat(result.getFacets().get("languages")).containsOnly(entry("xoo", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 10L));
+  }
+
+  @Test
+  public void facets_on_assignees() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file).setAssignee("steph"),
+      IssueTesting.newDoc("ISSUE2", file).setAssignee("simon"),
+      IssueTesting.newDoc("ISSUE3", file).setAssignee("simon"),
+      IssueTesting.newDoc("ISSUE4", file).setAssignee(null));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("assignees")));
+    assertThat(result.getFacets().getNames()).containsOnly("assignees", "debt");
+    assertThat(result.getFacets().get("assignees")).containsOnly(entry("steph", 10L), entry("simon", 20L), entry("", 10L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 40L));
+  }
+
+  @Test
+  public void facets_on_authors() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    indexIssues(
+      IssueTesting.newDoc("ISSUE1", file).setAuthorLogin("steph"),
+      IssueTesting.newDoc("ISSUE2", file).setAuthorLogin("simon"),
+      IssueTesting.newDoc("ISSUE3", file).setAuthorLogin("simon"),
+      IssueTesting.newDoc("ISSUE4", file).setAuthorLogin(null));
+
+    SearchResult<IssueDoc> result = index.search(newQueryBuilder().build(), new SearchOptions().addFacets(newArrayList("authors")));
+    assertThat(result.getFacets().getNames()).containsOnly("authors", "debt");
+    assertThat(result.getFacets().get("authors")).containsOnly(entry("steph", 10L), entry("simon", 20L));
+    assertThat(result.getFacets().get("debt")).containsOnly(entry("total", 40L));
+  }
+
+  @Test
+  public void facet_on_created_at() {
+    SearchOptions SearchOptions = fixtureForCreatedAtFacet();
+
+    Map<String, Long> createdAt = index.search(newQueryBuilder()
+        .createdBefore(DateUtils.parseDateTime("2016-01-01T00:00:00+0100")).build(),
+      SearchOptions).getFacets().get("createdAt");
+    assertThat(createdAt).containsOnly(
+      entry("2011-01-01T00:00:00+0000", 10L),
+      entry("2012-01-01T00:00:00+0000", 0L),
+      entry("2013-01-01T00:00:00+0000", 0L),
+      entry("2014-01-01T00:00:00+0000", 50L),
+      entry("2015-01-01T00:00:00+0000", 10L));
+  }
+
+  protected SearchOptions fixtureForCreatedAtFacet() {
+    ComponentDto project = ComponentTesting.newProjectDto();
+    ComponentDto file = ComponentTesting.newFileDto(project);
+
+    IssueDoc issue0 = IssueTesting.newDoc("ISSUE0", file).setFuncCreationDate(DateUtils.parseDateTime("2011-04-25T01:05:13+0100"));
+    IssueDoc issue1 = IssueTesting.newDoc("ISSUE1", file).setFuncCreationDate(DateUtils.parseDateTime("2014-09-01T12:34:56+0100"));
+    IssueDoc issue2 = IssueTesting.newDoc("ISSUE2", file).setFuncCreationDate(DateUtils.parseDateTime("2014-09-01T23:45:60+0100"));
+    IssueDoc issue3 = IssueTesting.newDoc("ISSUE3", file).setFuncCreationDate(DateUtils.parseDateTime("2014-09-02T12:34:56+0100"));
+    IssueDoc issue4 = IssueTesting.newDoc("ISSUE4", file).setFuncCreationDate(DateUtils.parseDateTime("2014-09-05T12:34:56+0100"));
+    IssueDoc issue5 = IssueTesting.newDoc("ISSUE5", file).setFuncCreationDate(DateUtils.parseDateTime("2014-09-20T12:34:56+0100"));
+    IssueDoc issue6 = IssueTesting.newDoc("ISSUE6", file).setFuncCreationDate(DateUtils.parseDateTime("2015-01-18T12:34:56+0100"));
+
+    indexIssues(issue0, issue1, issue2, issue3, issue4, issue5, issue6);
+
+    return new SearchOptions().addFacets("createdAt");
+  }
+
+  private void indexIssues(IssueDoc... issues) {
+    issueIndexer.index(Arrays.asList(issues).iterator());
+    for (IssueDoc issue : issues) {
+      addIssueAuthorization(issue.projectUuid(), DefaultGroups.ANYONE, null);
+    }
+  }
+
+  private void addIssueAuthorization(String projectUuid, @Nullable String group, @Nullable String user) {
+    issueAuthorizationIndexer.index(newArrayList(new IssueAuthorizationDao.Dto(projectUuid, 1).addGroup(group).addUser(user)));
+  }
+
+  private Builder newQueryBuilder() {
+    return IssueQuery.builder(userSessionRule).facetMode(IssueFilterParameters.FACET_MODE_DEBT);
+  }
+}
index b921a2f7de28999c2d8dc5e118edce19ffa09138..85daaa6f4d9d7f157ff04619be36261c7d09c636 100644 (file)
@@ -95,7 +95,7 @@ public class SearchActionMediumTest {
     assertThat(show.isPost()).isFalse();
     assertThat(show.isInternal()).isFalse();
     assertThat(show.responseExampleAsString()).isNotEmpty();
-    assertThat(show.params()).hasSize(39);
+    assertThat(show.params()).hasSize(40);
   }
 
   @Test
@@ -371,6 +371,31 @@ public class SearchActionMediumTest {
     result.assertJson(this.getClass(), "display_facets.json");
   }
 
+  @Test
+  public void display_facets_in_debt_mode() throws Exception {
+    ComponentDto project = insertComponent(ComponentTesting.newProjectDto("ABCD").setKey("MyProject"));
+    setDefaultProjectPermission(project);
+    ComponentDto file = insertComponent(ComponentTesting.newFileDto(project, "BCDE").setKey("MyComponent"));
+    IssueDto issue = IssueTesting.newDto(newRule(), file, project)
+      .setIssueCreationDate(DateUtils.parseDate("2014-09-04"))
+      .setIssueUpdateDate(DateUtils.parseDate("2017-12-04"))
+      .setDebt(10L)
+      .setStatus("OPEN")
+      .setKee("82fd47d4-b650-4037-80bc-7b112bd4eac2")
+      .setSeverity("MAJOR");
+    db.issueDao().insert(session, issue);
+    session.commit();
+    tester.get(IssueIndexer.class).indexAll();
+
+    userSessionRule.login("john");
+    WsTester.Result result = wsTester.newGetRequest(IssuesWs.API_ENDPOINT, SearchAction.SEARCH_ACTION)
+      .setParam("resolved", "false")
+      .setParam(WebService.Param.FACETS, "statuses,severities,resolutions,projectUuids,rules,fileUuids,assignees,languages,actionPlans")
+      .setParam("facetMode", "debt")
+      .execute();
+    result.assertJson(this.getClass(), "display_facets_debt.json");
+  }
+
   @Test
   public void display_zero_valued_facets_for_selected_items() throws Exception {
     ComponentDto project = insertComponent(ComponentTesting.newProjectDto("ABCD").setKey("MyProject"));
diff --git a/server/sonar-server/src/test/resources/org/sonar/server/issue/ws/SearchActionMediumTest/display_facets_debt.json b/server/sonar-server/src/test/resources/org/sonar/server/issue/ws/SearchActionMediumTest/display_facets_debt.json
new file mode 100644 (file)
index 0000000..1917038
--- /dev/null
@@ -0,0 +1,144 @@
+{
+  "debtTotal": 10,
+  "issues": [
+    {
+      "key": "82fd47d4-b650-4037-80bc-7b112bd4eac2",
+      "component": "MyComponent",
+      "project": "MyProject",
+      "rule": "xoo:x1",
+      "status": "OPEN",
+      "severity": "MAJOR",
+      "debt": "10min"
+    }
+  ],
+  "facets": [
+    {
+      "property": "statuses",
+      "values": [
+        {
+          "val": "OPEN",
+          "count": 10
+        },
+        {
+          "val": "CONFIRMED",
+          "count": 0
+        },
+        {
+          "val": "REOPENED",
+          "count": 0
+        },
+        {
+          "val": "RESOLVED",
+          "count": 0
+        },
+        {
+          "val": "CLOSED",
+          "count": 0
+        }
+      ]
+    },
+    {
+      "property": "severities",
+      "values": [
+        {
+          "val": "INFO",
+          "count": 0
+        },
+        {
+          "val": "MINOR",
+          "count": 0
+        },
+        {
+          "val": "MAJOR",
+          "count": 10
+        },
+        {
+          "val": "CRITICAL",
+          "count": 0
+        },
+        {
+          "val": "BLOCKER",
+          "count": 0
+        }
+      ]
+    },
+    {
+      "property": "resolutions",
+      "values": [
+        {
+          "val": "",
+          "count": 10
+        },
+        {
+          "val": "FALSE-POSITIVE",
+          "count": 0
+        },
+        {
+          "val": "FIXED",
+          "count": 0
+        },
+        {
+          "val": "REMOVED",
+          "count": 0
+        },
+        {
+          "val": "WONTFIX",
+          "count": 0
+        }
+      ]
+    },
+    {
+      "property": "projectUuids",
+      "values": [
+        {
+          "val": "ABCD",
+          "count": 10
+        }
+      ]
+    },
+    {
+      "property": "rules",
+      "values": [
+        {
+          "val": "xoo:x1",
+          "count": 10
+        }
+      ]
+    },
+    {
+      "property": "fileUuids",
+      "values": [
+        {
+          "val": "BCDE",
+          "count": 10
+        }
+      ]
+    },
+    {
+      "property": "assignees",
+      "values": [
+        {
+          "val": "",
+          "count": 10
+        }
+      ]
+    },
+    {
+      "property": "languages",
+      "values": [
+        {
+          "val": "xoo",
+          "count": 10
+        }
+      ]
+    },
+    {
+      "property": "actionPlans",
+      "values": [
+        {
+          "val": "",
+          "count": 10
+        }
+      ]
+    }
+  ]}