| // 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.index.query.Predicate; |
| import com.google.gerrit.index.query.QueryParseException; |
| 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.ProjectState; |
| 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.io.IOException; |
| 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.errors.ConfigInvalidException; |
| 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 Provider<InternalChangeQuery> queryProvider; |
| private final WorkQueue workQueue; |
| private final Provider<ReviewDb> dbProvider; |
| private final ApprovalsUtil approvalsUtil; |
| |
| @Inject |
| ReviewerRecommender( |
| ChangeQueryBuilder changeQueryBuilder, |
| DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap, |
| Provider<InternalChangeQuery> queryProvider, |
| 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.queryProvider = queryProvider; |
| this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap; |
| this.workQueue = workQueue; |
| this.dbProvider = dbProvider; |
| this.approvalsUtil = approvalsUtil; |
| } |
| |
| public List<Account.Id> suggestReviewers( |
| ChangeNotes changeNotes, |
| SuggestReviewers suggestReviewers, |
| ProjectState projectState, |
| List<Account.Id> candidateList) |
| throws OrmException, IOException, ConfigInvalidException { |
| 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, projectState, 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( |
| projectState.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, IOException, ConfigInvalidException { |
| // Get the user's last 25 changes, check approvals |
| try { |
| List<ChangeData> result = |
| queryProvider |
| .get() |
| .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, ProjectState projectState, double baseWeight) |
| throws OrmException, IOException, ConfigInvalidException { |
| // 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(projectState.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 = projectState.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 = |
| queryProvider.get().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; |
| } |
| } |