Line data Source code
1 : // Copyright (C) 2016 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.restapi.change;
16 :
17 : import static java.util.stream.Collectors.toList;
18 :
19 : import com.google.common.base.Strings;
20 : import com.google.common.collect.ImmutableList;
21 : import com.google.common.flogger.FluentLogger;
22 : import com.google.gerrit.common.Nullable;
23 : import com.google.gerrit.entities.Account;
24 : import com.google.gerrit.extensions.client.ReviewerState;
25 : import com.google.gerrit.index.query.QueryParseException;
26 : import com.google.gerrit.server.FanOutExecutor;
27 : import com.google.gerrit.server.account.AccountCache;
28 : import com.google.gerrit.server.account.AccountState;
29 : import com.google.gerrit.server.approval.ApprovalsUtil;
30 : import com.google.gerrit.server.change.ReviewerSuggestion;
31 : import com.google.gerrit.server.change.SuggestedReviewer;
32 : import com.google.gerrit.server.config.GerritServerConfig;
33 : import com.google.gerrit.server.index.change.ChangeField;
34 : import com.google.gerrit.server.notedb.ChangeNotes;
35 : import com.google.gerrit.server.notedb.ReviewerStateInternal;
36 : import com.google.gerrit.server.plugincontext.PluginMapContext;
37 : import com.google.gerrit.server.project.ProjectState;
38 : import com.google.gerrit.server.query.change.ChangeData;
39 : import com.google.gerrit.server.query.change.ChangeQueryBuilder;
40 : import com.google.gerrit.server.query.change.InternalChangeQuery;
41 : import com.google.inject.Inject;
42 : import com.google.inject.Provider;
43 : import java.io.IOException;
44 : import java.util.ArrayList;
45 : import java.util.Collections;
46 : import java.util.HashMap;
47 : import java.util.Iterator;
48 : import java.util.LinkedHashMap;
49 : import java.util.List;
50 : import java.util.Map;
51 : import java.util.Optional;
52 : import java.util.Set;
53 : import java.util.concurrent.Callable;
54 : import java.util.concurrent.ExecutionException;
55 : import java.util.concurrent.ExecutorService;
56 : import java.util.concurrent.Future;
57 : import java.util.concurrent.TimeUnit;
58 : import java.util.stream.Stream;
59 : import org.apache.commons.lang3.mutable.MutableDouble;
60 : import org.eclipse.jgit.errors.ConfigInvalidException;
61 : import org.eclipse.jgit.lib.Config;
62 :
63 : public class ReviewerRecommender {
64 91 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
65 :
66 : private static final long PLUGIN_QUERY_TIMEOUT = 500; // ms
67 :
68 : private final ChangeQueryBuilder changeQueryBuilder;
69 : private final Config config;
70 : private final PluginMapContext<ReviewerSuggestion> reviewerSuggestionPluginMap;
71 : private final Provider<InternalChangeQuery> queryProvider;
72 : private final ExecutorService executor;
73 : private final ApprovalsUtil approvalsUtil;
74 : private final AccountCache accountCache;
75 :
76 : @Inject
77 : ReviewerRecommender(
78 : ChangeQueryBuilder changeQueryBuilder,
79 : PluginMapContext<ReviewerSuggestion> reviewerSuggestionPluginMap,
80 : Provider<InternalChangeQuery> queryProvider,
81 : @FanOutExecutor ExecutorService executor,
82 : ApprovalsUtil approvalsUtil,
83 : @GerritServerConfig Config config,
84 91 : AccountCache accountCache) {
85 91 : this.changeQueryBuilder = changeQueryBuilder;
86 91 : this.config = config;
87 91 : this.queryProvider = queryProvider;
88 91 : this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
89 91 : this.executor = executor;
90 91 : this.approvalsUtil = approvalsUtil;
91 91 : this.accountCache = accountCache;
92 91 : }
93 :
94 : public List<Account.Id> suggestReviewers(
95 : ReviewerState reviewerState,
96 : @Nullable ChangeNotes changeNotes,
97 : SuggestReviewers suggestReviewers,
98 : ProjectState projectState,
99 : List<Account.Id> candidateList)
100 : throws IOException, ConfigInvalidException {
101 2 : logger.atFine().log("Candidates %s", candidateList);
102 :
103 2 : String query = suggestReviewers.getQuery();
104 2 : logger.atFine().log("query: %s", query);
105 :
106 2 : double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
107 2 : logger.atFine().log("base weight: %s", baseWeight);
108 :
109 2 : Map<Account.Id, MutableDouble> reviewerScores = baseRanking(baseWeight, query, candidateList);
110 2 : logger.atFine().log("Base reviewer scores: %s", reviewerScores);
111 :
112 : // Send the query along with a candidate list to all plugins and merge the
113 : // results. Plugins don't necessarily need to use the candidates list, they
114 : // can also return non-candidate account ids.
115 2 : List<Callable<Set<SuggestedReviewer>>> tasks =
116 2 : new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
117 2 : List<Double> weights = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
118 :
119 2 : reviewerSuggestionPluginMap.runEach(
120 : extension -> {
121 0 : tasks.add(
122 : () ->
123 : extension
124 0 : .get()
125 0 : .suggestReviewers(
126 0 : projectState.getNameKey(),
127 0 : changeNotes != null ? changeNotes.getChangeId() : null,
128 : query,
129 0 : reviewerScores.keySet()));
130 0 : String key = extension.getPluginName() + "-" + extension.getExportName();
131 0 : String pluginWeight = config.getString("addReviewer", key, "weight");
132 0 : if (Strings.isNullOrEmpty(pluginWeight)) {
133 0 : pluginWeight = "1";
134 : }
135 0 : logger.atFine().log("weight for %s: %s", key, pluginWeight);
136 : try {
137 0 : weights.add(Double.parseDouble(pluginWeight));
138 0 : } catch (NumberFormatException e) {
139 0 : logger.atSevere().withCause(e).log("Exception while parsing weight for %s", key);
140 0 : weights.add(1d);
141 0 : }
142 0 : });
143 :
144 : try {
145 2 : List<Future<Set<SuggestedReviewer>>> futures =
146 2 : executor.invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
147 2 : Iterator<Double> weightIterator = weights.iterator();
148 2 : for (Future<Set<SuggestedReviewer>> f : futures) {
149 0 : double weight = weightIterator.next();
150 0 : for (SuggestedReviewer s : f.get()) {
151 0 : if (reviewerScores.containsKey(s.account)) {
152 0 : reviewerScores.get(s.account).add(s.score * weight);
153 : } else {
154 0 : reviewerScores.put(s.account, new MutableDouble(s.score * weight));
155 : }
156 0 : }
157 0 : }
158 2 : logger.atFine().log("Reviewer scores: %s", reviewerScores);
159 0 : } catch (ExecutionException | InterruptedException e) {
160 0 : logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
161 0 : return ImmutableList.of();
162 2 : }
163 :
164 2 : if (changeNotes != null) {
165 : // Remove change owner
166 2 : if (reviewerScores.remove(changeNotes.getChange().getOwner()) != null) {
167 1 : logger.atFine().log("Remove change owner %s", changeNotes.getChange().getOwner());
168 : }
169 :
170 : // Remove existing reviewers
171 2 : approvalsUtil
172 2 : .getReviewers(changeNotes)
173 2 : .byState(ReviewerStateInternal.fromReviewerState(reviewerState))
174 2 : .forEach(
175 : r -> {
176 1 : if (reviewerScores.remove(r) != null) {
177 1 : logger.atFine().log("Remove existing reviewer %s", r);
178 : }
179 1 : });
180 : }
181 :
182 : // Sort results
183 2 : Stream<Map.Entry<Account.Id, MutableDouble>> sorted =
184 2 : reviewerScores.entrySet().stream()
185 2 : .sorted(Map.Entry.comparingByValue(Collections.reverseOrder()));
186 2 : List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
187 2 : logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
188 2 : return sortedSuggestions;
189 : }
190 :
191 : /**
192 : * @param baseWeight The weight applied to the ordering of the reviewers.
193 : * @param query Query to match. For example, it can try to match all users that start with "Ab".
194 : * @param candidateList The list of candidates based on the query. If query is empty, this list is
195 : * also empty.
196 : * @return Map of account ids that match the query and their appropriate ranking (the better the
197 : * ranking, the better it is to suggest them as reviewers).
198 : * @throws IOException Can't find owner="self" account.
199 : * @throws ConfigInvalidException Can't find owner="self" account.
200 : */
201 : private Map<Account.Id, MutableDouble> baseRanking(
202 : double baseWeight, String query, List<Account.Id> candidateList)
203 : throws IOException, ConfigInvalidException {
204 2 : int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50);
205 : // Get the user's last numberOfRelevantChanges changes, check reviewers
206 : try {
207 2 : List<ChangeData> result =
208 : queryProvider
209 2 : .get()
210 2 : .setLimit(numberOfRelevantChanges)
211 2 : .setRequestedFields(ChangeField.REVIEWER_SPEC)
212 2 : .query(changeQueryBuilder.owner("self"));
213 2 : Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
214 : // Put those candidates at the bottom of the list
215 2 : candidateList.stream().forEach(id -> suggestions.put(id, new MutableDouble(0)));
216 :
217 2 : for (ChangeData cd : result) {
218 2 : for (Account.Id reviewer : cd.reviewers().all()) {
219 1 : if (accountMatchesQuery(reviewer, query)) {
220 1 : suggestions
221 1 : .computeIfAbsent(reviewer, (ignored) -> new MutableDouble(0))
222 1 : .add(baseWeight);
223 : }
224 1 : }
225 2 : }
226 2 : return suggestions;
227 0 : } catch (QueryParseException e) {
228 : // Unhandled, because owner:self will never provoke a QueryParseException
229 0 : logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
230 0 : return new HashMap<>();
231 : }
232 : }
233 :
234 : private boolean accountMatchesQuery(Account.Id id, String query) {
235 1 : Optional<Account> account = accountCache.get(id).map(AccountState::account);
236 1 : if (account.isPresent() && account.get().isActive()) {
237 1 : if (Strings.isNullOrEmpty(query)
238 1 : || (account.get().fullName() != null && account.get().fullName().startsWith(query))
239 0 : || (account.get().preferredEmail() != null
240 0 : && account.get().preferredEmail().startsWith(query))) {
241 1 : return true;
242 : }
243 : }
244 1 : return false;
245 : }
246 : }
|