This commit adds a serializer for the NotifyConfig entitiy. The eventual goal is that we serialize CachedProjectConfig. The entity is too large to be serialized directly, though, so we divide and conquer. This commit moves the AutoValue representation to the entities package to allow the serializer packages to keep its dependencies minimal. Change-Id: I6876624bbe5d99c3ca187984f1214bf2066850fa
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.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.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();
|
|
}
|
|
}
|