473 lines
18 KiB
Java
473 lines
18 KiB
Java
// 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.common.data.GroupReference;
|
|
import com.google.gerrit.entities.Account;
|
|
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.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.FieldDef;
|
|
import com.google.gerrit.index.IndexConfig;
|
|
import com.google.gerrit.index.QueryOptions;
|
|
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.change.ReviewerAdder;
|
|
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.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 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;
|
|
|
|
@Inject
|
|
ReviewersUtil(
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
// Check if change is visible to reviewer and if the current user can see reviewer
|
|
if (visibilityControl.isVisibleTo(reviewer)
|
|
&& accountControlFactory.get().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.getValue(AccountField.ID).intValue());
|
|
}
|
|
return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
|
|
}
|
|
|
|
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().useLegacyNumericFields();
|
|
FieldDef<AccountState, ?> idField =
|
|
useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
|
|
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: " + maxAllowedWithoutConfirmation);
|
|
|
|
if (!ReviewerAdder.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();
|
|
}
|
|
}
|