Fix reviewer suggestion

Reviewer suggestion for non-empty queries was slow and inaccurate. Before this
change, the behavior was as following:

1. For an empty query, check the previous 25 changes of the current user,
and find all the users that reviewed his change, and those who reviewed
the most changes will be ranked higher.

2. For a non-empty query, generate n*3 (n = 6 in the frontend) account
names that start with the query. E.g, for query "a" it would generate
18 account names that start with "a", and then it would rank them based
on many things (comments, approvals, etc). This is bad, since there are
much more than 18 accounts that start with "a", so the results are not
good.

In this change, the behavior for an empty query stayed the same, and for
a non-empty query the behavior is as following:
Check the previous 25 changes of the current user and find all the
people that reviewed his change that their name or email starts with
the query. E.g, if I reviewed a change, and then the owner of the change
queries "Ga" it would find me (unless 6 other people that their name
starts with "Ga" and reviewed his change).
In case there are less than 6 people that fit the query described above,
we will also find up to 6 people that just start with the name. For "a"
it will still be quite random, but for "paiki" it likely will yield good
results even if I never reviewed this user's changes.

Therefore, users are no longer ranked by comments, owning a change, etc.
They are ranked only by reviews. It also doesn't matter which project it
is they reviewed (reviewerRanking and reviewerRankingProjectIsolation
are both irrelevant tests that were deleted).

Change-Id: I0ca718d35e52b3e0794419d1f56c431af52df0b6
This commit is contained in:
Gal Paikin
2019-09-06 16:02:58 +02:00
parent 124f89dfe6
commit d73a1eccfb
3 changed files with 95 additions and 179 deletions

View File

@@ -21,14 +21,14 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.FanOutExecutor;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.change.ReviewerSuggestion;
import com.google.gerrit.server.change.SuggestedReviewer;
import com.google.gerrit.server.config.GerritServerConfig;
@@ -45,11 +45,11 @@ import com.google.inject.Provider;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
@@ -79,6 +79,7 @@ public class ReviewerRecommender {
private final Provider<InternalChangeQuery> queryProvider;
private final ExecutorService executor;
private final ApprovalsUtil approvalsUtil;
private final AccountCache accountCache;
@Inject
ReviewerRecommender(
@@ -87,13 +88,15 @@ public class ReviewerRecommender {
Provider<InternalChangeQuery> queryProvider,
@FanOutExecutor ExecutorService executor,
ApprovalsUtil approvalsUtil,
@GerritServerConfig Config config) {
@GerritServerConfig Config config,
AccountCache accountCache) {
this.changeQueryBuilder = changeQueryBuilder;
this.config = config;
this.queryProvider = queryProvider;
this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
this.executor = executor;
this.approvalsUtil = approvalsUtil;
this.accountCache = accountCache;
}
public List<Account.Id> suggestReviewers(
@@ -111,12 +114,7 @@ public class ReviewerRecommender {
double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
logger.atFine().log("base weight: %s", baseWeight);
Map<Account.Id, MutableDouble> reviewerScores;
if (Strings.isNullOrEmpty(query)) {
reviewerScores = baseRankingForEmptyQuery(baseWeight);
} else {
reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
}
Map<Account.Id, MutableDouble> reviewerScores = baseRanking(baseWeight, query, candidateList);
logger.atFine().log("Base reviewer scores: %s", reviewerScores);
// Send the query along with a candidate list to all plugins and merge the
@@ -198,7 +196,18 @@ public class ReviewerRecommender {
return sortedSuggestions;
}
private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
/**
* @param baseWeight The weight applied to the ordering of the reviewers.
* @param query Query to match. For example, it can try to match all users that start with "Ab".
* @param candidateList The list of candidates based on the query. If query is empty, this list is
* also empty.
* @return Map of account ids that match the query and their appropriate ranking (the better the
* ranking, the better it is to suggest them as reviewers).
* @throws IOException Can't find owner="self" account.
* @throws ConfigInvalidException Can't find owner="self" account.
*/
private Map<Account.Id, MutableDouble> baseRanking(
double baseWeight, String query, List<Account.Id> candidateList)
throws IOException, ConfigInvalidException {
// Get the user's last 25 changes, check approvals
try {
@@ -208,14 +217,15 @@ public class ReviewerRecommender {
.setLimit(25)
.setRequestedFields(ChangeField.APPROVAL)
.query(changeQueryBuilder.owner("self"));
Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
// Put those candidates at the bottom of the list
candidateList.stream().forEach(id -> suggestions.put(id, new MutableDouble(0)));
for (ChangeData cd : result) {
for (PatchSetApproval approval : cd.currentApprovals()) {
Account.Id id = approval.accountId();
if (suggestions.containsKey(id)) {
suggestions.get(id).add(baseWeight);
} else {
suggestions.put(id, new MutableDouble(baseWeight));
if (Strings.isNullOrEmpty(query) || accountMatchesQuery(id, query)) {
suggestions.computeIfAbsent(id, (ignored) -> new MutableDouble(0)).add(baseWeight);
}
}
}
@@ -227,63 +237,15 @@ public class ReviewerRecommender {
}
}
private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
List<Account.Id> candidates, ProjectState projectState, double baseWeight)
throws IOException, ConfigInvalidException {
// Get each reviewer's activity based on number of applied labels
// (weighted 10d), number of comments (weighted 0.5d) and number of owned
// changes (weighted 1d).
Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
if (candidates.size() == 0) {
return reviewers;
}
List<Predicate<ChangeData>> predicates = new ArrayList<>();
for (Account.Id id : candidates) {
try {
Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectState.getName());
// Get all labels for this project and create a compound OR query to
// fetch all changes where users have applied one of these labels
List<LabelType> labelTypes = projectState.getLabelTypes().getLabelTypes();
List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size());
for (LabelType type : labelTypes) {
labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id));
}
Predicate<ChangeData> reviewerQuery =
Predicate.and(projectQuery, Predicate.or(labelPredicates));
Predicate<ChangeData> ownerQuery =
Predicate.and(projectQuery, changeQueryBuilder.owner(id.toString()));
Predicate<ChangeData> commentedByQuery =
Predicate.and(projectQuery, changeQueryBuilder.commentby(id.toString()));
predicates.add(reviewerQuery);
predicates.add(ownerQuery);
predicates.add(commentedByQuery);
reviewers.put(id, new MutableDouble());
} catch (QueryParseException e) {
// Unhandled: If an exception is thrown, we won't increase the
// candidates's score
logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
private boolean accountMatchesQuery(Account.Id id, String query) {
Optional<Account> account = accountCache.get(id).map(AccountState::account);
if (account.isPresent() && account.get().isActive()) {
if ((account.get().fullName() != null && account.get().fullName().startsWith(query))
|| (account.get().preferredEmail() != null
&& account.get().preferredEmail().startsWith(query))) {
return true;
}
}
List<List<ChangeData>> result = queryProvider.get().setLimit(25).noFields().query(predicates);
Iterator<List<ChangeData>> queryResultIterator = result.iterator();
Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
int i = 0;
Account.Id currentId = null;
while (queryResultIterator.hasNext()) {
List<ChangeData> currentResult = queryResultIterator.next();
if (i % WEIGHTS.length == 0) {
currentId = reviewersIterator.next();
}
reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] * baseWeight * currentResult.size());
i++;
}
return reviewers;
return false;
}
}

View File

@@ -251,7 +251,7 @@ public class ReviewersUtil {
QueryOptions.create(
indexConfig,
0,
suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
suggestReviewers.getLimit(),
ImmutableSet.of(AccountField.ID.getName())))
.readRaw();
List<Account.Id> matches =

View File

@@ -432,107 +432,27 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
assertThat(reviewers).isEmpty();
}
@Test
@GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
public void reviewerRanking() throws Exception {
// Assert that user are ranked by the number of times they have applied a
// a label to a change (highest), added comments (medium) or owned a
// change (low).
String fullName = "Primum Finalis";
TestAccount userWhoOwns = user("customuser1", fullName);
TestAccount reviewer1 = user("customuser2", fullName);
TestAccount reviewer2 = user("customuser3", fullName);
TestAccount userWhoComments = user("customuser4", fullName);
TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
// Create a change as userWhoOwns and add some reviews
requestScopeOperations.setApiUser(userWhoOwns.id());
String changeId1 = createChangeFromApi();
requestScopeOperations.setApiUser(reviewer1.id());
reviewChange(changeId1);
requestScopeOperations.setApiUser(user1.id());
String changeId2 = createChangeFromApi();
requestScopeOperations.setApiUser(reviewer1.id());
reviewChange(changeId2);
requestScopeOperations.setApiUser(reviewer2.id());
reviewChange(changeId2);
// Create a comment as a different user
requestScopeOperations.setApiUser(userWhoComments.id());
ReviewInput ri = new ReviewInput();
ri.message = "Test";
gApi.changes().id(changeId1).revision(1).review(ri);
// Create a change as a new user to assert that we receive the correct
// ranking
requestScopeOperations.setApiUser(userWhoLooksForSuggestions.id());
List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Pri", 4);
assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
.containsExactly(
reviewer1.id().get(),
reviewer2.id().get(),
userWhoOwns.id().get(),
userWhoComments.id().get())
.inOrder();
}
@Test
public void reviewerRankingProjectIsolation() throws Exception {
// Create new project
Project.NameKey newProject = projectOperations.newProject().create();
// Create users who review changes in both the default and the new project
String fullName = "Primum Finalis";
TestAccount userWhoOwns = user("customuser1", fullName);
TestAccount reviewer1 = user("customuser2", fullName);
TestAccount reviewer2 = user("customuser3", fullName);
requestScopeOperations.setApiUser(userWhoOwns.id());
String changeId1 = createChangeFromApi();
requestScopeOperations.setApiUser(reviewer1.id());
reviewChange(changeId1);
requestScopeOperations.setApiUser(userWhoOwns.id());
String changeId2 = createChangeFromApi(newProject);
requestScopeOperations.setApiUser(reviewer2.id());
reviewChange(changeId2);
requestScopeOperations.setApiUser(userWhoOwns.id());
String changeId3 = createChangeFromApi(newProject);
requestScopeOperations.setApiUser(reviewer2.id());
reviewChange(changeId3);
requestScopeOperations.setApiUser(userWhoOwns.id());
List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Prim", 4);
// Assert that reviewer1 is on top, even though reviewer2 has more reviews
// in other projects
assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
.containsExactly(reviewer1.id().get(), reviewer2.id().get())
.inOrder();
}
@Test
public void suggestNoInactiveAccounts() throws Exception {
requestScopeOperations.setApiUser(user.id());
String changeIdReviewed = createChangeFromApi();
String changeId = createChangeFromApi();
String name = name("foo");
TestAccount foo1 = accountCreator.create(name + "-1");
requestScopeOperations.setApiUser(foo1.id());
reviewChange(changeIdReviewed);
assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo2.id());
reviewChange(changeIdReviewed);
assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
String changeId = createChange().getChangeId();
assertReviewers(
suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
requestScopeOperations.setApiUser(user.id());
gApi.accounts().id(foo2.username()).setActive(false);
assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
@@ -540,11 +460,19 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
@Test
public void suggestNoExistingReviewers() throws Exception {
requestScopeOperations.setApiUser(user.id());
String changeId = createChangeFromApi();
String changeIdReviewed = createChangeFromApi();
String name = name("foo");
TestAccount foo1 = accountCreator.create(name + "-1");
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo1.id());
reviewChange(changeIdReviewed);
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo2.id());
reviewChange(changeIdReviewed);
String changeId = createChange().getChangeId();
assertReviewers(
suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
@@ -554,11 +482,19 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
@Test
public void suggestCcAsReviewer() throws Exception {
requestScopeOperations.setApiUser(user.id());
String changeId = createChangeFromApi();
String changeIdReviewed = createChangeFromApi();
String name = name("foo");
TestAccount foo1 = accountCreator.create(name + "-1");
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo1.id());
reviewChange(changeIdReviewed);
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo2.id());
reviewChange(changeIdReviewed);
String changeId = createChange().getChangeId();
assertReviewers(
suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
@@ -572,11 +508,19 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
@Test
public void suggestReviewerAsCc() throws Exception {
requestScopeOperations.setApiUser(user.id());
String changeId = createChangeFromApi();
String changeIdReviewed = createChangeFromApi();
String name = name("foo");
TestAccount foo1 = accountCreator.create(name + "-1");
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo1.id());
reviewChange(changeIdReviewed);
TestAccount foo2 = accountCreator.create(name + "-2");
requestScopeOperations.setApiUser(foo2.id());
reviewChange(changeIdReviewed);
String changeId = createChange().getChangeId();
assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
AddReviewerInput reviewerInput = new AddReviewerInput();
@@ -591,25 +535,17 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
String secondaryEmail = "foo.secondary@example.com";
TestAccount foo = createAccountWithSecondaryEmail("foo", secondaryEmail);
List<SuggestedReviewerInfo> reviewers =
suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
reviewers = suggestReviewers(createChange().getChangeId(), "secondary", 4);
List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "secondary", 4);
assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
}
@Test
public void cannotSuggestBySecondaryEmailWithoutModifyAccount() throws Exception {
String secondaryEmail = "foo.secondary@example.com";
createAccountWithSecondaryEmail("foo", secondaryEmail);
TestAccount foo = createAccountWithSecondaryEmail("foo", secondaryEmail);
requestScopeOperations.setApiUser(user.id());
List<SuggestedReviewerInfo> reviewers =
suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
assertThat(reviewers).isEmpty();
reviewers = suggestReviewers(createChange().getChangeId(), "secondary2", 4);
List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "secondary", 4);
assertThat(reviewers).isEmpty();
}
@@ -630,6 +566,24 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
assertThat(Iterables.getOnlyElement(reviewers).account.secondaryEmails).isNull();
}
@Test
public void suggestsPeopleWithNoReviewsWhenExplicitlyQueried() throws Exception {
TestAccount newTeamMember = accountCreator.create("newTeamMember");
requestScopeOperations.setApiUser(user.id());
String changeId = createChangeFromApi();
String changeIdReviewed = createChangeFromApi();
TestAccount reviewer = accountCreator.create("newReviewer");
requestScopeOperations.setApiUser(reviewer.id());
reviewChange(changeIdReviewed);
List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, "new", 4);
assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
.containsExactly(reviewer.id().get(), newTeamMember.id().get())
.inOrder();
}
private TestAccount createAccountWithSecondaryEmail(String name, String secondaryEmail)
throws Exception {
TestAccount foo = accountCreator.create(name(name), "foo.primary@example.com", "Foo");