// Copyright (C) 2016 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.gerrit.server; import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER; import static java.util.stream.Collectors.toList; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.gerrit.common.data.LabelType; import com.google.gerrit.extensions.registration.DynamicMap; import com.google.gerrit.reviewdb.client.Account; import com.google.gerrit.reviewdb.client.PatchSetApproval; import com.google.gerrit.reviewdb.server.ReviewDb; import com.google.gerrit.server.account.AccountDirectory.FillOptions; import com.google.gerrit.server.account.AccountLoader; import com.google.gerrit.server.change.ReviewerSuggestion; import com.google.gerrit.server.change.SuggestReviewers; import com.google.gerrit.server.change.SuggestedReviewer; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.git.WorkQueue; import com.google.gerrit.server.index.change.ChangeField; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.project.ProjectControl; import com.google.gerrit.server.query.Predicate; import com.google.gerrit.server.query.QueryParseException; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.query.change.ChangeQueryBuilder; import com.google.gerrit.server.query.change.InternalChangeQuery; import com.google.gwtorm.server.OrmException; import com.google.inject.Inject; import com.google.inject.Provider; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import org.apache.commons.lang.mutable.MutableDouble; import org.eclipse.jgit.lib.Config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ReviewerRecommender { private static final Logger log = LoggerFactory.getLogger(ReviewerRecommender.class); private static final double BASE_REVIEWER_WEIGHT = 10; private static final double BASE_OWNER_WEIGHT = 1; private static final double BASE_COMMENT_WEIGHT = 0.5; private static final double[] WEIGHTS = new double[] { BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT, }; private static final long PLUGIN_QUERY_TIMEOUT = 500; //ms private final ChangeQueryBuilder changeQueryBuilder; private final Config config; private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap; private final InternalChangeQuery internalChangeQuery; private final WorkQueue workQueue; private final Provider<ReviewDb> dbProvider; private final ApprovalsUtil approvalsUtil; @Inject ReviewerRecommender( ChangeQueryBuilder changeQueryBuilder, DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap, InternalChangeQuery internalChangeQuery, WorkQueue workQueue, Provider<ReviewDb> dbProvider, ApprovalsUtil approvalsUtil, @GerritServerConfig Config config) { Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS); fillOptions.addAll(AccountLoader.DETAILED_OPTIONS); this.changeQueryBuilder = changeQueryBuilder; this.config = config; this.internalChangeQuery = internalChangeQuery; this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap; this.workQueue = workQueue; this.dbProvider = dbProvider; this.approvalsUtil = approvalsUtil; } public List<Account.Id> suggestReviewers( ChangeNotes changeNotes, SuggestReviewers suggestReviewers, ProjectControl projectControl, List<Account.Id> candidateList) throws OrmException { String query = suggestReviewers.getQuery(); double baseWeight = config.getInt("addReviewer", "baseWeight", 1); Map<Account.Id, MutableDouble> reviewerScores; if (Strings.isNullOrEmpty(query)) { reviewerScores = baseRankingForEmptyQuery(baseWeight); } else { reviewerScores = baseRankingForCandidateList(candidateList, projectControl, baseWeight); } // Send the query along with a candidate list to all plugins and merge the // results. Plugins don't necessarily need to use the candidates list, they // can also return non-candidate account ids. List<Callable<Set<SuggestedReviewer>>> tasks = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size()); List<Double> weights = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size()); for (DynamicMap.Entry<ReviewerSuggestion> plugin : reviewerSuggestionPluginMap) { tasks.add( () -> plugin .getProvider() .get() .suggestReviewers( projectControl.getProject().getNameKey(), changeNotes.getChangeId(), query, reviewerScores.keySet())); String pluginWeight = config.getString( "addReviewer", plugin.getPluginName() + "-" + plugin.getExportName(), "weight"); if (Strings.isNullOrEmpty(pluginWeight)) { pluginWeight = "1"; } try { weights.add(Double.parseDouble(pluginWeight)); } catch (NumberFormatException e) { log.error( "Exception while parsing weight for " + plugin.getPluginName() + "-" + plugin.getExportName(), e); weights.add(1d); } } try { List<Future<Set<SuggestedReviewer>>> futures = workQueue.getDefaultQueue().invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS); Iterator<Double> weightIterator = weights.iterator(); for (Future<Set<SuggestedReviewer>> f : futures) { double weight = weightIterator.next(); for (SuggestedReviewer s : f.get()) { if (reviewerScores.containsKey(s.account)) { reviewerScores.get(s.account).add(s.score * weight); } else { reviewerScores.put(s.account, new MutableDouble(s.score * weight)); } } } } catch (ExecutionException | InterruptedException e) { log.error("Exception while suggesting reviewers", e); return ImmutableList.of(); } if (changeNotes != null) { // Remove change owner reviewerScores.remove(changeNotes.getChange().getOwner()); // Remove existing reviewers reviewerScores .keySet() .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER)); } // Sort results Stream<Entry<Account.Id, MutableDouble>> sorted = reviewerScores .entrySet() .stream() .sorted(Collections.reverseOrder(Map.Entry.comparingByValue())); List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList()); return sortedSuggestions; } private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight) throws OrmException { // Get the user's last 25 changes, check approvals try { List<ChangeData> result = internalChangeQuery .setLimit(25) .setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName())) .query(changeQueryBuilder.owner("self")); Map<Account.Id, MutableDouble> suggestions = new HashMap<>(); for (ChangeData cd : result) { for (PatchSetApproval approval : cd.currentApprovals()) { Account.Id id = approval.getAccountId(); if (suggestions.containsKey(id)) { suggestions.get(id).add(baseWeight); } else { suggestions.put(id, new MutableDouble(baseWeight)); } } } return suggestions; } catch (QueryParseException e) { // Unhandled, because owner:self will never provoke a QueryParseException log.error("Exception while suggesting reviewers", e); return ImmutableMap.of(); } } private Map<Account.Id, MutableDouble> baseRankingForCandidateList( List<Account.Id> candidates, ProjectControl projectControl, double baseWeight) throws OrmException { // Get each reviewer's activity based on number of applied labels // (weighted 10d), number of comments (weighted 0.5d) and number of owned // changes (weighted 1d). Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>(); if (candidates.size() == 0) { return reviewers; } List<Predicate<ChangeData>> predicates = new ArrayList<>(); for (Account.Id id : candidates) { try { Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectControl.getProject().getName()); // Get all labels for this project and create a compound OR query to // fetch all changes where users have applied one of these labels List<LabelType> labelTypes = projectControl.getLabelTypes().getLabelTypes(); List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size()); for (LabelType type : labelTypes) { labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id)); } Predicate<ChangeData> reviewerQuery = Predicate.and(projectQuery, Predicate.or(labelPredicates)); Predicate<ChangeData> ownerQuery = Predicate.and(projectQuery, changeQueryBuilder.owner(id.toString())); Predicate<ChangeData> commentedByQuery = Predicate.and(projectQuery, changeQueryBuilder.commentby(id.toString())); predicates.add(reviewerQuery); predicates.add(ownerQuery); predicates.add(commentedByQuery); reviewers.put(id, new MutableDouble()); } catch (QueryParseException e) { // Unhandled: If an exception is thrown, we won't increase the // candidates's score log.error("Exception while suggesting reviewers", e); } } List<List<ChangeData>> result = internalChangeQuery.setLimit(25).setRequestedFields(ImmutableSet.of()).query(predicates); Iterator<List<ChangeData>> queryResultIterator = result.iterator(); Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator(); int i = 0; Account.Id currentId = null; while (queryResultIterator.hasNext()) { List<ChangeData> currentResult = queryResultIterator.next(); if (i % WEIGHTS.length == 0) { currentId = reviewersIterator.next(); } reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] * baseWeight * currentResult.size()); i++; } return reviewers; } }