blob: 05648d575811ee3b2da72ef26d8efb3db2b4014a [file] [log] [blame]
// 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)));
}
// More accounts are suggested here than the requested limit because
// visibility filtering will be applied later.
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() + 30,
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.getQuery(), 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();
}
}