| // 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 com.google.common.flogger.LazyArgs.lazy; | |
| import static java.util.stream.Collectors.toList; | |
| import com.google.common.base.Strings; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableSet; | |
| import com.google.common.collect.Sets; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.entities.Account; | |
| import com.google.gerrit.entities.GroupReference; | |
| import com.google.gerrit.entities.Project; | |
| import com.google.gerrit.exceptions.StorageException; | |
| import com.google.gerrit.extensions.client.ReviewerState; | |
| import com.google.gerrit.extensions.common.AccountVisibility; | |
| import com.google.gerrit.extensions.common.GroupBaseInfo; | |
| import com.google.gerrit.extensions.common.SuggestedReviewerInfo; | |
| import com.google.gerrit.extensions.restapi.BadRequestException; | |
| import com.google.gerrit.extensions.restapi.Url; | |
| import com.google.gerrit.index.IndexConfig; | |
| import com.google.gerrit.index.QueryOptions; | |
| import com.google.gerrit.index.SchemaFieldDefs.SchemaField; | |
| import com.google.gerrit.index.query.FieldBundle; | |
| import com.google.gerrit.index.query.Predicate; | |
| import com.google.gerrit.index.query.QueryParseException; | |
| import com.google.gerrit.index.query.ResultSet; | |
| import com.google.gerrit.index.query.TooManyTermsInQueryException; | |
| import com.google.gerrit.metrics.Description; | |
| import com.google.gerrit.metrics.Description.Units; | |
| import com.google.gerrit.metrics.MetricMaker; | |
| import com.google.gerrit.metrics.Timer0; | |
| import com.google.gerrit.server.CurrentUser; | |
| import com.google.gerrit.server.account.AccountControl; | |
| import com.google.gerrit.server.account.AccountDirectory.FillOptions; | |
| import com.google.gerrit.server.account.AccountLoader; | |
| import com.google.gerrit.server.account.AccountState; | |
| import com.google.gerrit.server.account.GroupBackend; | |
| import com.google.gerrit.server.account.GroupMembers; | |
| import com.google.gerrit.server.account.ServiceUserClassifier; | |
| import com.google.gerrit.server.change.ReviewerModifier; | |
| import com.google.gerrit.server.index.account.AccountField; | |
| import com.google.gerrit.server.index.account.AccountIndexCollection; | |
| import com.google.gerrit.server.index.account.AccountIndexRewriter; | |
| import com.google.gerrit.server.notedb.ChangeNotes; | |
| import com.google.gerrit.server.permissions.GlobalPermission; | |
| import com.google.gerrit.server.permissions.PermissionBackendException; | |
| import com.google.gerrit.server.project.NoSuchProjectException; | |
| import com.google.gerrit.server.project.ProjectState; | |
| import com.google.gerrit.server.query.account.AccountPredicates; | |
| import com.google.gerrit.server.query.account.AccountQueryBuilder; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Provider; | |
| import com.google.inject.Singleton; | |
| import java.io.IOException; | |
| import java.util.ArrayList; | |
| import java.util.Collections; | |
| import java.util.EnumSet; | |
| import java.util.List; | |
| import java.util.Objects; | |
| import java.util.Set; | |
| import org.eclipse.jgit.errors.ConfigInvalidException; | |
| public class ReviewersUtil { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| @Singleton | |
| private static class Metrics { | |
| final Timer0 queryAccountsLatency; | |
| final Timer0 recommendAccountsLatency; | |
| final Timer0 loadAccountsLatency; | |
| final Timer0 queryGroupsLatency; | |
| final Timer0 filterVisibility; | |
| @Inject | |
| Metrics(MetricMaker metricMaker) { | |
| queryAccountsLatency = | |
| metricMaker.newTimer( | |
| "reviewer_suggestion/query_accounts", | |
| new Description("Latency for querying accounts for reviewer suggestion") | |
| .setCumulative() | |
| .setUnit(Units.MILLISECONDS)); | |
| recommendAccountsLatency = | |
| metricMaker.newTimer( | |
| "reviewer_suggestion/recommend_accounts", | |
| new Description("Latency for recommending accounts for reviewer suggestion") | |
| .setCumulative() | |
| .setUnit(Units.MILLISECONDS)); | |
| loadAccountsLatency = | |
| metricMaker.newTimer( | |
| "reviewer_suggestion/load_accounts", | |
| new Description("Latency for loading accounts for reviewer suggestion") | |
| .setCumulative() | |
| .setUnit(Units.MILLISECONDS)); | |
| queryGroupsLatency = | |
| metricMaker.newTimer( | |
| "reviewer_suggestion/query_groups", | |
| new Description("Latency for querying groups for reviewer suggestion") | |
| .setCumulative() | |
| .setUnit(Units.MILLISECONDS)); | |
| filterVisibility = | |
| metricMaker.newTimer( | |
| "reviewer_suggestion/filter_visibility", | |
| new Description("Latency for removing users that can't see the change") | |
| .setCumulative() | |
| .setUnit(Units.MILLISECONDS)); | |
| } | |
| } | |
| private final AccountVisibility accountVisibility; | |
| private final AccountLoader.Factory accountLoaderFactory; | |
| private final AccountQueryBuilder accountQueryBuilder; | |
| private final AccountIndexRewriter accountIndexRewriter; | |
| private final GroupBackend groupBackend; | |
| private final GroupMembers groupMembers; | |
| private final ReviewerRecommender reviewerRecommender; | |
| private final Metrics metrics; | |
| private final AccountIndexCollection accountIndexes; | |
| private final IndexConfig indexConfig; | |
| private final AccountControl.Factory accountControlFactory; | |
| private final Provider<CurrentUser> self; | |
| private final ServiceUserClassifier serviceUserClassifier; | |
| @Inject | |
| ReviewersUtil( | |
| AccountVisibility accountVisibility, | |
| AccountLoader.Factory accountLoaderFactory, | |
| AccountQueryBuilder accountQueryBuilder, | |
| AccountIndexRewriter accountIndexRewriter, | |
| GroupBackend groupBackend, | |
| GroupMembers groupMembers, | |
| ReviewerRecommender reviewerRecommender, | |
| Metrics metrics, | |
| AccountIndexCollection accountIndexes, | |
| IndexConfig indexConfig, | |
| AccountControl.Factory accountControlFactory, | |
| Provider<CurrentUser> self, | |
| ServiceUserClassifier serviceUserClassifier) { | |
| this.accountVisibility = accountVisibility; | |
| this.accountLoaderFactory = accountLoaderFactory; | |
| this.accountQueryBuilder = accountQueryBuilder; | |
| this.accountIndexRewriter = accountIndexRewriter; | |
| this.groupBackend = groupBackend; | |
| this.groupMembers = groupMembers; | |
| this.reviewerRecommender = reviewerRecommender; | |
| this.metrics = metrics; | |
| this.accountIndexes = accountIndexes; | |
| this.indexConfig = indexConfig; | |
| this.accountControlFactory = accountControlFactory; | |
| this.self = self; | |
| this.serviceUserClassifier = serviceUserClassifier; | |
| } | |
| public interface VisibilityControl { | |
| boolean isVisibleTo(Account.Id account); | |
| } | |
| public List<SuggestedReviewerInfo> suggestReviewers( | |
| ReviewerState reviewerState, | |
| @Nullable ChangeNotes changeNotes, | |
| SuggestReviewers suggestReviewers, | |
| ProjectState projectState, | |
| VisibilityControl visibilityControl, | |
| boolean excludeGroups) | |
| throws IOException, ConfigInvalidException, PermissionBackendException, BadRequestException { | |
| CurrentUser currentUser = self.get(); | |
| if (changeNotes != null) { | |
| logger.atFine().log( | |
| "Suggesting reviewers for change %s to user %s.", | |
| changeNotes.getChangeId().get(), currentUser.getLoggableName()); | |
| } else { | |
| logger.atFine().log( | |
| "Suggesting default reviewers for project %s to user %s.", | |
| projectState.getName(), currentUser.getLoggableName()); | |
| } | |
| String query = suggestReviewers.getQuery(); | |
| logger.atFine().log("Query: %s", query); | |
| int limit = suggestReviewers.getLimit(); | |
| if (!suggestReviewers.getSuggestAccounts()) { | |
| logger.atFine().log("Reviewer suggestion is disabled."); | |
| return Collections.emptyList(); | |
| } | |
| AccountControl accountControl = accountControlFactory.get(); | |
| if (accountVisibility == AccountVisibility.NONE && !accountControl.canViewAll()) { | |
| logger.atFine().log( | |
| "Not suggesting reviewers: accountVisibility = %s and the user does not have %s capability", | |
| AccountVisibility.NONE, GlobalPermission.VIEW_ALL_ACCOUNTS); | |
| return Collections.emptyList(); | |
| } | |
| List<Account.Id> candidateList = new ArrayList<>(); | |
| if (!Strings.isNullOrEmpty(query)) { | |
| candidateList = suggestAccounts(suggestReviewers); | |
| logger.atFine().log("Candidate list: %s", candidateList); | |
| } | |
| List<Account.Id> sortedRecommendations = | |
| recommendAccounts( | |
| reviewerState, changeNotes, suggestReviewers, projectState, candidateList); | |
| logger.atFine().log("Sorted recommendations: %s", sortedRecommendations); | |
| // Filter accounts by visibility, skip service users and enforce limit | |
| List<Account.Id> filteredRecommendations = new ArrayList<>(); | |
| try (Timer0.Context ctx = metrics.filterVisibility.start()) { | |
| for (Account.Id reviewer : sortedRecommendations) { | |
| if (filteredRecommendations.size() >= limit) { | |
| break; | |
| } | |
| if (suggestReviewers.isSkipServiceUsers() | |
| && serviceUserClassifier.isServiceUser(reviewer)) { | |
| continue; | |
| } | |
| // Check if change is visible to reviewer and if the current user can see reviewer | |
| if (visibilityControl.isVisibleTo(reviewer) && accountControl.canSee(reviewer)) { | |
| filteredRecommendations.add(reviewer); | |
| } | |
| } | |
| } | |
| logger.atFine().log("Filtered recommendations: %s", filteredRecommendations); | |
| List<SuggestedReviewerInfo> suggestedReviewers = | |
| suggestReviewers( | |
| suggestReviewers, | |
| projectState, | |
| visibilityControl, | |
| excludeGroups, | |
| filteredRecommendations); | |
| logger.atFine().log( | |
| "Suggested reviewers: %s", lazy(() -> formatSuggestedReviewers(suggestedReviewers))); | |
| return suggestedReviewers; | |
| } | |
| private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) { | |
| if (useLegacyNumericFields) { | |
| return Account.id(f.<Integer>getValue(AccountField.ID_FIELD_SPEC).intValue()); | |
| } | |
| return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC))); | |
| } | |
| private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) | |
| throws BadRequestException { | |
| try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) { | |
| // For performance reasons we don't use AccountQueryProvider as it would always load the | |
| // complete account from the cache (or worse, from NoteDb) even though we only need the ID | |
| // which we can directly get from the returned results. | |
| Predicate<AccountState> pred = | |
| Predicate.and( | |
| AccountPredicates.isActive(), | |
| accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())); | |
| logger.atFine().log("accounts index query: %s", pred); | |
| accountIndexRewriter.validateMaxTermsInQuery(pred); | |
| boolean useLegacyNumericFields = | |
| accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID_FIELD_SPEC); | |
| SchemaField<AccountState, ?> idField = | |
| useLegacyNumericFields ? AccountField.ID_FIELD_SPEC : AccountField.ID_STR_FIELD_SPEC; | |
| ResultSet<FieldBundle> result = | |
| accountIndexes | |
| .getSearchIndex() | |
| .getSource( | |
| pred, | |
| QueryOptions.create( | |
| indexConfig, | |
| 0, | |
| suggestReviewers.getLimit(), | |
| ImmutableSet.of(idField.getName()))) | |
| .readRaw(); | |
| List<Account.Id> matches = | |
| result.toList().stream() | |
| .map(f -> fromIdField(f, useLegacyNumericFields)) | |
| .collect(toList()); | |
| logger.atFine().log("Matches: %s", matches); | |
| return matches; | |
| } catch (TooManyTermsInQueryException e) { | |
| throw new BadRequestException(e.getMessage()); | |
| } catch (QueryParseException e) { | |
| logger.atWarning().withCause(e).log("Suggesting accounts failed, return empty result."); | |
| return ImmutableList.of(); | |
| } catch (StorageException e) { | |
| if (e.getCause() instanceof TooManyTermsInQueryException) { | |
| throw new BadRequestException(e.getMessage()); | |
| } | |
| if (e.getCause() instanceof QueryParseException) { | |
| return ImmutableList.of(); | |
| } | |
| throw e; | |
| } | |
| } | |
| private List<SuggestedReviewerInfo> suggestReviewers( | |
| SuggestReviewers suggestReviewers, | |
| ProjectState projectState, | |
| VisibilityControl visibilityControl, | |
| boolean excludeGroups, | |
| List<Account.Id> filteredRecommendations) | |
| throws PermissionBackendException, IOException { | |
| List<SuggestedReviewerInfo> suggestedReviewers = loadAccounts(filteredRecommendations); | |
| int limit = suggestReviewers.getLimit(); | |
| if (!excludeGroups | |
| && suggestedReviewers.size() < limit | |
| && !Strings.isNullOrEmpty(suggestReviewers.getQuery())) { | |
| // Add groups at the end as individual accounts are usually more | |
| // important. | |
| suggestedReviewers.addAll( | |
| suggestAccountGroups( | |
| suggestReviewers, | |
| projectState, | |
| visibilityControl, | |
| limit - suggestedReviewers.size())); | |
| } | |
| if (suggestedReviewers.size() > limit) { | |
| suggestedReviewers = suggestedReviewers.subList(0, limit); | |
| logger.atFine().log("Limited suggested reviewers to %d accounts.", limit); | |
| } | |
| return suggestedReviewers; | |
| } | |
| private List<Account.Id> recommendAccounts( | |
| ReviewerState reviewerState, | |
| @Nullable ChangeNotes changeNotes, | |
| SuggestReviewers suggestReviewers, | |
| ProjectState projectState, | |
| List<Account.Id> candidateList) | |
| throws IOException, ConfigInvalidException { | |
| try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) { | |
| return reviewerRecommender.suggestReviewers( | |
| reviewerState, changeNotes, suggestReviewers, projectState, candidateList); | |
| } | |
| } | |
| private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds) | |
| throws PermissionBackendException { | |
| Set<FillOptions> fillOptions = | |
| Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS)); | |
| AccountLoader accountLoader = accountLoaderFactory.create(fillOptions); | |
| try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) { | |
| List<SuggestedReviewerInfo> reviewer = | |
| accountIds.stream() | |
| .map(accountLoader::get) | |
| .filter(Objects::nonNull) | |
| .map( | |
| a -> { | |
| SuggestedReviewerInfo info = new SuggestedReviewerInfo(); | |
| info.account = a; | |
| info.count = 1; | |
| return info; | |
| }) | |
| .collect(toList()); | |
| accountLoader.fill(); | |
| return reviewer; | |
| } | |
| } | |
| private List<SuggestedReviewerInfo> suggestAccountGroups( | |
| SuggestReviewers suggestReviewers, | |
| ProjectState projectState, | |
| VisibilityControl visibilityControl, | |
| int limit) | |
| throws IOException { | |
| try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) { | |
| List<SuggestedReviewerInfo> groups = new ArrayList<>(); | |
| for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) { | |
| GroupAsReviewer result = | |
| suggestGroupAsReviewer( | |
| suggestReviewers, projectState.getProject(), g, visibilityControl); | |
| if (result.allowed || result.allowedWithConfirmation) { | |
| GroupBaseInfo info = new GroupBaseInfo(); | |
| info.id = Url.encode(g.getUUID().get()); | |
| info.name = g.getName(); | |
| SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo(); | |
| suggestedReviewerInfo.group = info; | |
| suggestedReviewerInfo.count = result.size; | |
| if (result.allowedWithConfirmation) { | |
| suggestedReviewerInfo.confirm = true; | |
| } | |
| groups.add(suggestedReviewerInfo); | |
| if (groups.size() >= limit) { | |
| break; | |
| } | |
| } | |
| } | |
| return groups; | |
| } | |
| } | |
| private List<GroupReference> suggestAccountGroups( | |
| SuggestReviewers suggestReviewers, ProjectState projectState) { | |
| return groupBackend.suggest(suggestReviewers.getQuery(), projectState).stream() | |
| .limit(suggestReviewers.getLimit()) | |
| .collect(toList()); | |
| } | |
| private static class GroupAsReviewer { | |
| boolean allowed; | |
| boolean allowedWithConfirmation; | |
| int size; | |
| } | |
| private GroupAsReviewer suggestGroupAsReviewer( | |
| SuggestReviewers suggestReviewers, | |
| Project project, | |
| GroupReference group, | |
| VisibilityControl visibilityControl) | |
| throws IOException { | |
| GroupAsReviewer result = new GroupAsReviewer(); | |
| int maxAllowed = suggestReviewers.getMaxAllowed(); | |
| int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation(); | |
| logger.atFine().log("maxAllowedWithoutConfirmation: %s", maxAllowedWithoutConfirmation); | |
| if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) { | |
| logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID()); | |
| return result; | |
| } | |
| try { | |
| Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey()); | |
| if (members.isEmpty()) { | |
| logger.atFine().log("Ignore group %s since it has no members", group.getUUID()); | |
| return result; | |
| } | |
| result.size = members.size(); | |
| if (maxAllowed > 0 && result.size > maxAllowed) { | |
| return result; | |
| } | |
| boolean needsConfirmation = | |
| maxAllowedWithoutConfirmation > 0 && result.size > maxAllowedWithoutConfirmation; | |
| if (needsConfirmation) { | |
| logger.atFine().log( | |
| "group %s needs confirmation to be added as reviewer, it has %d members", | |
| group.getUUID(), result.size); | |
| } | |
| // require that at least one member in the group can see the change | |
| for (Account account : members) { | |
| if (visibilityControl.isVisibleTo(account.id())) { | |
| if (needsConfirmation) { | |
| result.allowedWithConfirmation = true; | |
| } else { | |
| result.allowed = true; | |
| } | |
| logger.atFine().log("Suggest group %s", group.getUUID()); | |
| return result; | |
| } | |
| } | |
| logger.atFine().log( | |
| "Ignore group %s since none of its members can see the change", group.getUUID()); | |
| } catch (NoSuchProjectException e) { | |
| return result; | |
| } | |
| return result; | |
| } | |
| private static String formatSuggestedReviewers(List<SuggestedReviewerInfo> suggestedReviewers) { | |
| return suggestedReviewers.stream() | |
| .map( | |
| r -> { | |
| if (r.account != null) { | |
| return "a/" + r.account._accountId; | |
| } else if (r.group != null) { | |
| return "g/" + r.group.id; | |
| } else { | |
| return ""; | |
| } | |
| }) | |
| .collect(toList()) | |
| .toString(); | |
| } | |
| } |