| // 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.restapi.change; |
| |
| import static java.util.stream.Collectors.toList; |
| |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.extensions.client.ReviewerState; |
| import com.google.gerrit.index.query.QueryParseException; |
| import com.google.gerrit.server.FanOutExecutor; |
| import com.google.gerrit.server.account.AccountCache; |
| import com.google.gerrit.server.account.AccountState; |
| import com.google.gerrit.server.approval.ApprovalsUtil; |
| import com.google.gerrit.server.change.ReviewerSuggestion; |
| import com.google.gerrit.server.change.SuggestedReviewer; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gerrit.server.index.change.ChangeField; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.notedb.ReviewerStateInternal; |
| import com.google.gerrit.server.plugincontext.PluginMapContext; |
| 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.inject.Inject; |
| import com.google.inject.Provider; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.concurrent.Callable; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Future; |
| import java.util.concurrent.TimeUnit; |
| import java.util.stream.Stream; |
| import org.apache.commons.lang3.mutable.MutableDouble; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.Config; |
| |
| public class ReviewerRecommender { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private static final long PLUGIN_QUERY_TIMEOUT = 500; // ms |
| |
| private final ChangeQueryBuilder changeQueryBuilder; |
| private final Config config; |
| private final PluginMapContext<ReviewerSuggestion> reviewerSuggestionPluginMap; |
| private final Provider<InternalChangeQuery> queryProvider; |
| private final ExecutorService executor; |
| private final ApprovalsUtil approvalsUtil; |
| private final AccountCache accountCache; |
| |
| @Inject |
| ReviewerRecommender( |
| ChangeQueryBuilder changeQueryBuilder, |
| PluginMapContext<ReviewerSuggestion> reviewerSuggestionPluginMap, |
| Provider<InternalChangeQuery> queryProvider, |
| @FanOutExecutor ExecutorService executor, |
| ApprovalsUtil approvalsUtil, |
| @GerritServerConfig Config config, |
| AccountCache accountCache) { |
| this.changeQueryBuilder = changeQueryBuilder; |
| this.config = config; |
| this.queryProvider = queryProvider; |
| this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap; |
| this.executor = executor; |
| this.approvalsUtil = approvalsUtil; |
| this.accountCache = accountCache; |
| } |
| |
| public List<Account.Id> suggestReviewers( |
| ReviewerState reviewerState, |
| @Nullable ChangeNotes changeNotes, |
| SuggestReviewers suggestReviewers, |
| ProjectState projectState, |
| List<Account.Id> candidateList) |
| throws IOException, ConfigInvalidException { |
| logger.atFine().log("Candidates %s", candidateList); |
| |
| String query = suggestReviewers.getQuery(); |
| logger.atFine().log("query: %s", query); |
| |
| double baseWeight = config.getInt("addReviewer", "baseWeight", 1); |
| logger.atFine().log("base weight: %s", baseWeight); |
| |
| Map<Account.Id, MutableDouble> reviewerScores = baseRanking(baseWeight, query, candidateList); |
| logger.atFine().log("Base reviewer scores: %s", reviewerScores); |
| |
| // 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()); |
| |
| reviewerSuggestionPluginMap.runEach( |
| extension -> { |
| tasks.add( |
| () -> |
| extension |
| .get() |
| .suggestReviewers( |
| projectState.getNameKey(), |
| changeNotes != null ? changeNotes.getChangeId() : null, |
| query, |
| reviewerScores.keySet())); |
| String key = extension.getPluginName() + "-" + extension.getExportName(); |
| String pluginWeight = config.getString("addReviewer", key, "weight"); |
| if (Strings.isNullOrEmpty(pluginWeight)) { |
| pluginWeight = "1"; |
| } |
| logger.atFine().log("weight for %s: %s", key, pluginWeight); |
| try { |
| weights.add(Double.parseDouble(pluginWeight)); |
| } catch (NumberFormatException e) { |
| logger.atSevere().withCause(e).log("Exception while parsing weight for %s", key); |
| weights.add(1d); |
| } |
| }); |
| |
| try { |
| List<Future<Set<SuggestedReviewer>>> futures = |
| executor.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)); |
| } |
| } |
| } |
| logger.atFine().log("Reviewer scores: %s", reviewerScores); |
| } catch (ExecutionException | InterruptedException e) { |
| logger.atSevere().withCause(e).log("Exception while suggesting reviewers"); |
| return ImmutableList.of(); |
| } |
| |
| if (changeNotes != null) { |
| // Remove change owner |
| if (reviewerScores.remove(changeNotes.getChange().getOwner()) != null) { |
| logger.atFine().log("Remove change owner %s", changeNotes.getChange().getOwner()); |
| } |
| |
| // Remove existing reviewers |
| approvalsUtil |
| .getReviewers(changeNotes) |
| .byState(ReviewerStateInternal.fromReviewerState(reviewerState)) |
| .forEach( |
| r -> { |
| if (reviewerScores.remove(r) != null) { |
| logger.atFine().log("Remove existing reviewer %s", r); |
| } |
| }); |
| } |
| |
| // Sort results |
| Stream<Map.Entry<Account.Id, MutableDouble>> sorted = |
| reviewerScores.entrySet().stream() |
| .sorted(Map.Entry.comparingByValue(Collections.reverseOrder())); |
| List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList()); |
| logger.atFine().log("Sorted suggestions: %s", sortedSuggestions); |
| return sortedSuggestions; |
| } |
| |
| /** |
| * @param baseWeight The weight applied to the ordering of the reviewers. |
| * @param query Query to match. For example, it can try to match all users that start with "Ab". |
| * @param candidateList The list of candidates based on the query. If query is empty, this list is |
| * also empty. |
| * @return Map of account ids that match the query and their appropriate ranking (the better the |
| * ranking, the better it is to suggest them as reviewers). |
| * @throws IOException Can't find owner="self" account. |
| * @throws ConfigInvalidException Can't find owner="self" account. |
| */ |
| private Map<Account.Id, MutableDouble> baseRanking( |
| double baseWeight, String query, List<Account.Id> candidateList) |
| throws IOException, ConfigInvalidException { |
| int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50); |
| // Get the user's last numberOfRelevantChanges changes, check reviewers |
| try { |
| List<ChangeData> result = |
| queryProvider |
| .get() |
| .setLimit(numberOfRelevantChanges) |
| .setRequestedFields(ChangeField.REVIEWER) |
| .query(changeQueryBuilder.owner("self")); |
| Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>(); |
| // Put those candidates at the bottom of the list |
| candidateList.stream().forEach(id -> suggestions.put(id, new MutableDouble(0))); |
| |
| for (ChangeData cd : result) { |
| for (Account.Id reviewer : cd.reviewers().all()) { |
| if (accountMatchesQuery(reviewer, query)) { |
| suggestions |
| .computeIfAbsent(reviewer, (ignored) -> new MutableDouble(0)) |
| .add(baseWeight); |
| } |
| } |
| } |
| return suggestions; |
| } catch (QueryParseException e) { |
| // Unhandled, because owner:self will never provoke a QueryParseException |
| logger.atSevere().withCause(e).log("Exception while suggesting reviewers"); |
| return new HashMap<>(); |
| } |
| } |
| |
| private boolean accountMatchesQuery(Account.Id id, String query) { |
| Optional<Account> account = accountCache.get(id).map(AccountState::account); |
| if (account.isPresent() && account.get().isActive()) { |
| if (Strings.isNullOrEmpty(query) |
| || (account.get().fullName() != null && account.get().fullName().startsWith(query)) |
| || (account.get().preferredEmail() != null |
| && account.get().preferredEmail().startsWith(query))) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |