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 com.google.common.flogger.LazyArgs.lazy;
18 : import static java.util.stream.Collectors.toList;
19 :
20 : import com.google.common.base.Strings;
21 : import com.google.common.collect.ImmutableList;
22 : import com.google.common.collect.ImmutableSet;
23 : import com.google.common.collect.Sets;
24 : import com.google.common.flogger.FluentLogger;
25 : import com.google.gerrit.common.Nullable;
26 : import com.google.gerrit.entities.Account;
27 : import com.google.gerrit.entities.GroupReference;
28 : import com.google.gerrit.entities.Project;
29 : import com.google.gerrit.exceptions.StorageException;
30 : import com.google.gerrit.extensions.client.ReviewerState;
31 : import com.google.gerrit.extensions.common.AccountVisibility;
32 : import com.google.gerrit.extensions.common.GroupBaseInfo;
33 : import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
34 : import com.google.gerrit.extensions.restapi.BadRequestException;
35 : import com.google.gerrit.extensions.restapi.Url;
36 : import com.google.gerrit.index.IndexConfig;
37 : import com.google.gerrit.index.QueryOptions;
38 : import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
39 : import com.google.gerrit.index.query.FieldBundle;
40 : import com.google.gerrit.index.query.Predicate;
41 : import com.google.gerrit.index.query.QueryParseException;
42 : import com.google.gerrit.index.query.ResultSet;
43 : import com.google.gerrit.index.query.TooManyTermsInQueryException;
44 : import com.google.gerrit.metrics.Description;
45 : import com.google.gerrit.metrics.Description.Units;
46 : import com.google.gerrit.metrics.MetricMaker;
47 : import com.google.gerrit.metrics.Timer0;
48 : import com.google.gerrit.server.CurrentUser;
49 : import com.google.gerrit.server.account.AccountControl;
50 : import com.google.gerrit.server.account.AccountDirectory.FillOptions;
51 : import com.google.gerrit.server.account.AccountLoader;
52 : import com.google.gerrit.server.account.AccountState;
53 : import com.google.gerrit.server.account.GroupBackend;
54 : import com.google.gerrit.server.account.GroupMembers;
55 : import com.google.gerrit.server.account.ServiceUserClassifier;
56 : import com.google.gerrit.server.change.ReviewerModifier;
57 : import com.google.gerrit.server.index.account.AccountField;
58 : import com.google.gerrit.server.index.account.AccountIndexCollection;
59 : import com.google.gerrit.server.index.account.AccountIndexRewriter;
60 : import com.google.gerrit.server.notedb.ChangeNotes;
61 : import com.google.gerrit.server.permissions.GlobalPermission;
62 : import com.google.gerrit.server.permissions.PermissionBackendException;
63 : import com.google.gerrit.server.project.NoSuchProjectException;
64 : import com.google.gerrit.server.project.ProjectState;
65 : import com.google.gerrit.server.query.account.AccountPredicates;
66 : import com.google.gerrit.server.query.account.AccountQueryBuilder;
67 : import com.google.inject.Inject;
68 : import com.google.inject.Provider;
69 : import com.google.inject.Singleton;
70 : import java.io.IOException;
71 : import java.util.ArrayList;
72 : import java.util.Collections;
73 : import java.util.EnumSet;
74 : import java.util.List;
75 : import java.util.Objects;
76 : import java.util.Set;
77 : import org.eclipse.jgit.errors.ConfigInvalidException;
78 :
79 : public class ReviewersUtil {
80 91 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
81 :
82 : @Singleton
83 : private static class Metrics {
84 : final Timer0 queryAccountsLatency;
85 : final Timer0 recommendAccountsLatency;
86 : final Timer0 loadAccountsLatency;
87 : final Timer0 queryGroupsLatency;
88 : final Timer0 filterVisibility;
89 :
90 : @Inject
91 91 : Metrics(MetricMaker metricMaker) {
92 91 : queryAccountsLatency =
93 91 : metricMaker.newTimer(
94 : "reviewer_suggestion/query_accounts",
95 : new Description("Latency for querying accounts for reviewer suggestion")
96 91 : .setCumulative()
97 91 : .setUnit(Units.MILLISECONDS));
98 91 : recommendAccountsLatency =
99 91 : metricMaker.newTimer(
100 : "reviewer_suggestion/recommend_accounts",
101 : new Description("Latency for recommending accounts for reviewer suggestion")
102 91 : .setCumulative()
103 91 : .setUnit(Units.MILLISECONDS));
104 91 : loadAccountsLatency =
105 91 : metricMaker.newTimer(
106 : "reviewer_suggestion/load_accounts",
107 : new Description("Latency for loading accounts for reviewer suggestion")
108 91 : .setCumulative()
109 91 : .setUnit(Units.MILLISECONDS));
110 91 : queryGroupsLatency =
111 91 : metricMaker.newTimer(
112 : "reviewer_suggestion/query_groups",
113 : new Description("Latency for querying groups for reviewer suggestion")
114 91 : .setCumulative()
115 91 : .setUnit(Units.MILLISECONDS));
116 91 : filterVisibility =
117 91 : metricMaker.newTimer(
118 : "reviewer_suggestion/filter_visibility",
119 : new Description("Latency for removing users that can't see the change")
120 91 : .setCumulative()
121 91 : .setUnit(Units.MILLISECONDS));
122 91 : }
123 : }
124 :
125 : private final AccountVisibility accountVisibility;
126 : private final AccountLoader.Factory accountLoaderFactory;
127 : private final AccountQueryBuilder accountQueryBuilder;
128 : private final AccountIndexRewriter accountIndexRewriter;
129 : private final GroupBackend groupBackend;
130 : private final GroupMembers groupMembers;
131 : private final ReviewerRecommender reviewerRecommender;
132 : private final Metrics metrics;
133 : private final AccountIndexCollection accountIndexes;
134 : private final IndexConfig indexConfig;
135 : private final AccountControl.Factory accountControlFactory;
136 : private final Provider<CurrentUser> self;
137 : private final ServiceUserClassifier serviceUserClassifier;
138 :
139 : @Inject
140 : ReviewersUtil(
141 : AccountVisibility accountVisibility,
142 : AccountLoader.Factory accountLoaderFactory,
143 : AccountQueryBuilder accountQueryBuilder,
144 : AccountIndexRewriter accountIndexRewriter,
145 : GroupBackend groupBackend,
146 : GroupMembers groupMembers,
147 : ReviewerRecommender reviewerRecommender,
148 : Metrics metrics,
149 : AccountIndexCollection accountIndexes,
150 : IndexConfig indexConfig,
151 : AccountControl.Factory accountControlFactory,
152 : Provider<CurrentUser> self,
153 91 : ServiceUserClassifier serviceUserClassifier) {
154 91 : this.accountVisibility = accountVisibility;
155 91 : this.accountLoaderFactory = accountLoaderFactory;
156 91 : this.accountQueryBuilder = accountQueryBuilder;
157 91 : this.accountIndexRewriter = accountIndexRewriter;
158 91 : this.groupBackend = groupBackend;
159 91 : this.groupMembers = groupMembers;
160 91 : this.reviewerRecommender = reviewerRecommender;
161 91 : this.metrics = metrics;
162 91 : this.accountIndexes = accountIndexes;
163 91 : this.indexConfig = indexConfig;
164 91 : this.accountControlFactory = accountControlFactory;
165 91 : this.self = self;
166 91 : this.serviceUserClassifier = serviceUserClassifier;
167 91 : }
168 :
169 : public interface VisibilityControl {
170 : boolean isVisibleTo(Account.Id account);
171 : }
172 :
173 : public List<SuggestedReviewerInfo> suggestReviewers(
174 : ReviewerState reviewerState,
175 : @Nullable ChangeNotes changeNotes,
176 : SuggestReviewers suggestReviewers,
177 : ProjectState projectState,
178 : VisibilityControl visibilityControl,
179 : boolean excludeGroups)
180 : throws IOException, ConfigInvalidException, PermissionBackendException, BadRequestException {
181 2 : CurrentUser currentUser = self.get();
182 2 : if (changeNotes != null) {
183 2 : logger.atFine().log(
184 : "Suggesting reviewers for change %s to user %s.",
185 2 : changeNotes.getChangeId().get(), currentUser.getLoggableName());
186 : } else {
187 0 : logger.atFine().log(
188 : "Suggesting default reviewers for project %s to user %s.",
189 0 : projectState.getName(), currentUser.getLoggableName());
190 : }
191 :
192 2 : String query = suggestReviewers.getQuery();
193 2 : logger.atFine().log("Query: %s", query);
194 2 : int limit = suggestReviewers.getLimit();
195 :
196 2 : if (!suggestReviewers.getSuggestAccounts()) {
197 1 : logger.atFine().log("Reviewer suggestion is disabled.");
198 1 : return Collections.emptyList();
199 : }
200 2 : AccountControl accountControl = accountControlFactory.get();
201 :
202 2 : if (accountVisibility == AccountVisibility.NONE && !accountControl.canViewAll()) {
203 1 : logger.atFine().log(
204 : "Not suggesting reviewers: accountVisibility = %s and the user does not have %s capability",
205 : AccountVisibility.NONE, GlobalPermission.VIEW_ALL_ACCOUNTS);
206 1 : return Collections.emptyList();
207 : }
208 :
209 2 : List<Account.Id> candidateList = new ArrayList<>();
210 2 : if (!Strings.isNullOrEmpty(query)) {
211 1 : candidateList = suggestAccounts(suggestReviewers);
212 1 : logger.atFine().log("Candidate list: %s", candidateList);
213 : }
214 2 : List<Account.Id> sortedRecommendations =
215 2 : recommendAccounts(
216 : reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
217 2 : logger.atFine().log("Sorted recommendations: %s", sortedRecommendations);
218 :
219 : // Filter accounts by visibility, skip service users and enforce limit
220 2 : List<Account.Id> filteredRecommendations = new ArrayList<>();
221 2 : try (Timer0.Context ctx = metrics.filterVisibility.start()) {
222 2 : for (Account.Id reviewer : sortedRecommendations) {
223 1 : if (filteredRecommendations.size() >= limit) {
224 0 : break;
225 : }
226 1 : if (suggestReviewers.isSkipServiceUsers()
227 1 : && serviceUserClassifier.isServiceUser(reviewer)) {
228 1 : continue;
229 : }
230 : // Check if change is visible to reviewer and if the current user can see reviewer
231 1 : if (visibilityControl.isVisibleTo(reviewer) && accountControl.canSee(reviewer)) {
232 1 : filteredRecommendations.add(reviewer);
233 : }
234 1 : }
235 : }
236 2 : logger.atFine().log("Filtered recommendations: %s", filteredRecommendations);
237 :
238 2 : List<SuggestedReviewerInfo> suggestedReviewers =
239 2 : suggestReviewers(
240 : suggestReviewers,
241 : projectState,
242 : visibilityControl,
243 : excludeGroups,
244 : filteredRecommendations);
245 2 : logger.atFine().log(
246 2 : "Suggested reviewers: %s", lazy(() -> formatSuggestedReviewers(suggestedReviewers)));
247 2 : return suggestedReviewers;
248 : }
249 :
250 : private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
251 1 : if (useLegacyNumericFields) {
252 0 : return Account.id(f.<Integer>getValue(AccountField.ID_FIELD_SPEC).intValue());
253 : }
254 1 : return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC)));
255 : }
256 :
257 : private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
258 : throws BadRequestException {
259 1 : try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
260 : // For performance reasons we don't use AccountQueryProvider as it would always load the
261 : // complete account from the cache (or worse, from NoteDb) even though we only need the ID
262 : // which we can directly get from the returned results.
263 1 : Predicate<AccountState> pred =
264 1 : Predicate.and(
265 1 : AccountPredicates.isActive(),
266 1 : accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
267 1 : logger.atFine().log("accounts index query: %s", pred);
268 1 : accountIndexRewriter.validateMaxTermsInQuery(pred);
269 1 : boolean useLegacyNumericFields =
270 1 : accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID_FIELD_SPEC);
271 : SchemaField<AccountState, ?> idField =
272 1 : useLegacyNumericFields ? AccountField.ID_FIELD_SPEC : AccountField.ID_STR_FIELD_SPEC;
273 1 : ResultSet<FieldBundle> result =
274 : accountIndexes
275 1 : .getSearchIndex()
276 1 : .getSource(
277 : pred,
278 1 : QueryOptions.create(
279 : indexConfig,
280 : 0,
281 1 : suggestReviewers.getLimit(),
282 1 : ImmutableSet.of(idField.getName())))
283 1 : .readRaw();
284 1 : List<Account.Id> matches =
285 1 : result.toList().stream()
286 1 : .map(f -> fromIdField(f, useLegacyNumericFields))
287 1 : .collect(toList());
288 1 : logger.atFine().log("Matches: %s", matches);
289 1 : return matches;
290 1 : } catch (TooManyTermsInQueryException e) {
291 1 : throw new BadRequestException(e.getMessage());
292 0 : } catch (QueryParseException e) {
293 0 : logger.atWarning().withCause(e).log("Suggesting accounts failed, return empty result.");
294 0 : return ImmutableList.of();
295 0 : } catch (StorageException e) {
296 0 : if (e.getCause() instanceof TooManyTermsInQueryException) {
297 0 : throw new BadRequestException(e.getMessage());
298 : }
299 0 : if (e.getCause() instanceof QueryParseException) {
300 0 : return ImmutableList.of();
301 : }
302 0 : throw e;
303 : }
304 : }
305 :
306 : private List<SuggestedReviewerInfo> suggestReviewers(
307 : SuggestReviewers suggestReviewers,
308 : ProjectState projectState,
309 : VisibilityControl visibilityControl,
310 : boolean excludeGroups,
311 : List<Account.Id> filteredRecommendations)
312 : throws PermissionBackendException, IOException {
313 2 : List<SuggestedReviewerInfo> suggestedReviewers = loadAccounts(filteredRecommendations);
314 :
315 2 : int limit = suggestReviewers.getLimit();
316 2 : if (!excludeGroups
317 2 : && suggestedReviewers.size() < limit
318 2 : && !Strings.isNullOrEmpty(suggestReviewers.getQuery())) {
319 : // Add groups at the end as individual accounts are usually more
320 : // important.
321 1 : suggestedReviewers.addAll(
322 1 : suggestAccountGroups(
323 : suggestReviewers,
324 : projectState,
325 : visibilityControl,
326 1 : limit - suggestedReviewers.size()));
327 : }
328 :
329 2 : if (suggestedReviewers.size() > limit) {
330 0 : suggestedReviewers = suggestedReviewers.subList(0, limit);
331 0 : logger.atFine().log("Limited suggested reviewers to %d accounts.", limit);
332 : }
333 2 : return suggestedReviewers;
334 : }
335 :
336 : private List<Account.Id> recommendAccounts(
337 : ReviewerState reviewerState,
338 : @Nullable ChangeNotes changeNotes,
339 : SuggestReviewers suggestReviewers,
340 : ProjectState projectState,
341 : List<Account.Id> candidateList)
342 : throws IOException, ConfigInvalidException {
343 2 : try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
344 2 : return reviewerRecommender.suggestReviewers(
345 : reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
346 : }
347 : }
348 :
349 : private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
350 : throws PermissionBackendException {
351 2 : Set<FillOptions> fillOptions =
352 2 : Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS));
353 2 : AccountLoader accountLoader = accountLoaderFactory.create(fillOptions);
354 :
355 2 : try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
356 2 : List<SuggestedReviewerInfo> reviewer =
357 2 : accountIds.stream()
358 2 : .map(accountLoader::get)
359 2 : .filter(Objects::nonNull)
360 2 : .map(
361 : a -> {
362 1 : SuggestedReviewerInfo info = new SuggestedReviewerInfo();
363 1 : info.account = a;
364 1 : info.count = 1;
365 1 : return info;
366 : })
367 2 : .collect(toList());
368 2 : accountLoader.fill();
369 2 : return reviewer;
370 : }
371 : }
372 :
373 : private List<SuggestedReviewerInfo> suggestAccountGroups(
374 : SuggestReviewers suggestReviewers,
375 : ProjectState projectState,
376 : VisibilityControl visibilityControl,
377 : int limit)
378 : throws IOException {
379 1 : try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
380 1 : List<SuggestedReviewerInfo> groups = new ArrayList<>();
381 1 : for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
382 1 : GroupAsReviewer result =
383 1 : suggestGroupAsReviewer(
384 1 : suggestReviewers, projectState.getProject(), g, visibilityControl);
385 1 : if (result.allowed || result.allowedWithConfirmation) {
386 1 : GroupBaseInfo info = new GroupBaseInfo();
387 1 : info.id = Url.encode(g.getUUID().get());
388 1 : info.name = g.getName();
389 1 : SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
390 1 : suggestedReviewerInfo.group = info;
391 1 : suggestedReviewerInfo.count = result.size;
392 1 : if (result.allowedWithConfirmation) {
393 1 : suggestedReviewerInfo.confirm = true;
394 : }
395 1 : groups.add(suggestedReviewerInfo);
396 1 : if (groups.size() >= limit) {
397 1 : break;
398 : }
399 : }
400 1 : }
401 1 : return groups;
402 : }
403 : }
404 :
405 : private List<GroupReference> suggestAccountGroups(
406 : SuggestReviewers suggestReviewers, ProjectState projectState) {
407 1 : return groupBackend.suggest(suggestReviewers.getQuery(), projectState).stream()
408 1 : .limit(suggestReviewers.getLimit())
409 1 : .collect(toList());
410 : }
411 :
412 : private static class GroupAsReviewer {
413 : boolean allowed;
414 : boolean allowedWithConfirmation;
415 : int size;
416 : }
417 :
418 : private GroupAsReviewer suggestGroupAsReviewer(
419 : SuggestReviewers suggestReviewers,
420 : Project project,
421 : GroupReference group,
422 : VisibilityControl visibilityControl)
423 : throws IOException {
424 1 : GroupAsReviewer result = new GroupAsReviewer();
425 1 : int maxAllowed = suggestReviewers.getMaxAllowed();
426 1 : int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
427 1 : logger.atFine().log("maxAllowedWithoutConfirmation: %s", maxAllowedWithoutConfirmation);
428 :
429 1 : if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
430 0 : logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
431 0 : return result;
432 : }
433 :
434 : try {
435 1 : Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
436 :
437 1 : if (members.isEmpty()) {
438 0 : logger.atFine().log("Ignore group %s since it has no members", group.getUUID());
439 0 : return result;
440 : }
441 :
442 1 : result.size = members.size();
443 1 : if (maxAllowed > 0 && result.size > maxAllowed) {
444 1 : return result;
445 : }
446 :
447 1 : boolean needsConfirmation =
448 : maxAllowedWithoutConfirmation > 0 && result.size > maxAllowedWithoutConfirmation;
449 1 : if (needsConfirmation) {
450 1 : logger.atFine().log(
451 : "group %s needs confirmation to be added as reviewer, it has %d members",
452 1 : group.getUUID(), result.size);
453 : }
454 :
455 : // require that at least one member in the group can see the change
456 1 : for (Account account : members) {
457 1 : if (visibilityControl.isVisibleTo(account.id())) {
458 1 : if (needsConfirmation) {
459 1 : result.allowedWithConfirmation = true;
460 : } else {
461 1 : result.allowed = true;
462 : }
463 1 : logger.atFine().log("Suggest group %s", group.getUUID());
464 1 : return result;
465 : }
466 0 : }
467 0 : logger.atFine().log(
468 0 : "Ignore group %s since none of its members can see the change", group.getUUID());
469 0 : } catch (NoSuchProjectException e) {
470 0 : return result;
471 0 : }
472 :
473 0 : return result;
474 : }
475 :
476 : private static String formatSuggestedReviewers(List<SuggestedReviewerInfo> suggestedReviewers) {
477 0 : return suggestedReviewers.stream()
478 0 : .map(
479 : r -> {
480 0 : if (r.account != null) {
481 0 : return "a/" + r.account._accountId;
482 0 : } else if (r.group != null) {
483 0 : return "g/" + r.group.id;
484 : } else {
485 0 : return "";
486 : }
487 : })
488 0 : .collect(toList())
489 0 : .toString();
490 : }
491 : }
|