Build a Recommender for Reviewer Suggestion
Until now, reviewer suggestion was purely based on a search. I've built a small recommender to improve the suggestions based on past contributions by the individual reviewers and added an extension point so that people can customize this feature. The built-in recommender makes a default suggestion of reviewers before the user types a query. These are based on people that have reviewed the last contributions that a user made. If the user starts typing in the box, we generate a list of candidates using the account index and feed it into a small recommender. The recommender ranks the list by looking at recent contributions of the candidates made in the same project. Contributions include reviews, owned-changes and comments at different weights. Change-Id: I5aca23ddd2442146fd26bdc12e7c18da85de7ac1
This commit is contained in:
@@ -67,6 +67,15 @@ be added at once by adding a group as reviewer.
|
|||||||
+
|
+
|
||||||
Default is 20.
|
Default is 20.
|
||||||
|
|
||||||
|
[[addReviewer.baseWeight]]addReviewer.baseWeight::
|
||||||
|
+
|
||||||
|
The weight that will be applied in the default reviewer ranking algorithm.
|
||||||
|
This can be increased or decreased to give more or less influence to plugins.
|
||||||
|
If set to zero, the base ranking will not have any effect. Reviewers will then
|
||||||
|
be ordered as ranked by the plugins (if there are any).
|
||||||
|
+
|
||||||
|
By default 1.
|
||||||
|
|
||||||
[[auth]]
|
[[auth]]
|
||||||
=== Section auth
|
=== Section auth
|
||||||
|
|
||||||
@@ -3907,11 +3916,11 @@ By default 10.
|
|||||||
[[suggest.from]]suggest.from::
|
[[suggest.from]]suggest.from::
|
||||||
+
|
+
|
||||||
The number of characters that a user must have typed before suggestions
|
The number of characters that a user must have typed before suggestions
|
||||||
are provided. If set to 0, suggestions are always provided.
|
are provided. If set to 0, suggestions are always provided. This is only
|
||||||
|
used for suggesting accounts when adding members to a group.
|
||||||
+
|
+
|
||||||
By default 0.
|
By default 0.
|
||||||
|
|
||||||
|
|
||||||
[[theme]]
|
[[theme]]
|
||||||
=== Section theme
|
=== Section theme
|
||||||
|
|
||||||
|
|||||||
@@ -2399,6 +2399,41 @@ new RestApi("accounts").id("self").view("password.http")
|
|||||||
----
|
----
|
||||||
|
|
||||||
|
|
||||||
|
[[reviewer-suggestion]]
|
||||||
|
== Reviewer Suggestion Plugins
|
||||||
|
|
||||||
|
Gerrit provides an extension point that enables Plugins to rank
|
||||||
|
the list of reviewer suggestion a user receives upon clicking "Add Reviewer" on
|
||||||
|
the change screen.
|
||||||
|
Gerrit supports both a default suggestion that appears when the user has not yet
|
||||||
|
typed anything and a filtered suggestion that is shown as the user starts
|
||||||
|
typing.
|
||||||
|
Plugins receive a candidate list and can return a Set of suggested reviewers
|
||||||
|
containing the Account.Id and a score for each reviewer.
|
||||||
|
The candidate list is non-binding and plugins can choose to return reviewers not
|
||||||
|
initially contained in the candidate list.
|
||||||
|
Server administrators can configure the overall weight of each plugin using the
|
||||||
|
weight config parameter on [addreviewer "<pluginName-exportName>"].
|
||||||
|
|
||||||
|
[source, java]
|
||||||
|
----
|
||||||
|
import com.google.gerrit.server.change.ReviewerSuggestion;
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
import com.google.gerrit.reviewdb.client.Change;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class MyPlugin implements ReviewerSuggestion {
|
||||||
|
public Set<SuggestedReviewer> suggestReviewers(
|
||||||
|
Change.Id changeId, String query, Set<Account.Id> candidates){
|
||||||
|
Set<SuggestedReviewer> suggestions = new HashSet<>();
|
||||||
|
// Implement your ranking logic here
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
|
||||||
== SEE ALSO
|
== SEE ALSO
|
||||||
|
|
||||||
* link:js-api.html[JavaScript API]
|
* link:js-api.html[JavaScript API]
|
||||||
|
|||||||
@@ -20,15 +20,20 @@ import com.google.common.collect.Iterables;
|
|||||||
import com.google.gerrit.acceptance.AbstractDaemonTest;
|
import com.google.gerrit.acceptance.AbstractDaemonTest;
|
||||||
import com.google.gerrit.acceptance.GerritConfig;
|
import com.google.gerrit.acceptance.GerritConfig;
|
||||||
import com.google.gerrit.acceptance.GerritConfigs;
|
import com.google.gerrit.acceptance.GerritConfigs;
|
||||||
|
import com.google.gerrit.acceptance.Sandboxed;
|
||||||
import com.google.gerrit.acceptance.TestAccount;
|
import com.google.gerrit.acceptance.TestAccount;
|
||||||
import com.google.gerrit.common.data.GlobalCapability;
|
import com.google.gerrit.common.data.GlobalCapability;
|
||||||
import com.google.gerrit.common.data.GroupDescription;
|
import com.google.gerrit.common.data.GroupDescription;
|
||||||
import com.google.gerrit.common.data.GroupDescriptions;
|
import com.google.gerrit.common.data.GroupDescriptions;
|
||||||
|
import com.google.gerrit.extensions.api.changes.ReviewInput;
|
||||||
|
import com.google.gerrit.extensions.common.ChangeInput;
|
||||||
import com.google.gerrit.extensions.common.GroupInfo;
|
import com.google.gerrit.extensions.common.GroupInfo;
|
||||||
import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
|
import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
|
||||||
|
import com.google.gerrit.extensions.restapi.RestApiException;
|
||||||
import com.google.gerrit.extensions.restapi.TopLevelResource;
|
import com.google.gerrit.extensions.restapi.TopLevelResource;
|
||||||
import com.google.gerrit.extensions.restapi.Url;
|
import com.google.gerrit.extensions.restapi.Url;
|
||||||
import com.google.gerrit.reviewdb.client.AccountGroup;
|
import com.google.gerrit.reviewdb.client.AccountGroup;
|
||||||
|
import com.google.gerrit.reviewdb.client.Project;
|
||||||
import com.google.gerrit.server.group.CreateGroup;
|
import com.google.gerrit.server.group.CreateGroup;
|
||||||
import com.google.gerrit.server.group.GroupsCollection;
|
import com.google.gerrit.server.group.GroupsCollection;
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
@@ -38,7 +43,9 @@ import org.junit.Test;
|
|||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Sandboxed
|
||||||
public class SuggestReviewersIT extends AbstractDaemonTest {
|
public class SuggestReviewersIT extends AbstractDaemonTest {
|
||||||
@Inject
|
@Inject
|
||||||
private CreateGroup.Factory createGroupFactory;
|
private CreateGroup.Factory createGroupFactory;
|
||||||
@@ -88,15 +95,6 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
|
|||||||
assertThat(reviewers).isEmpty();
|
assertThat(reviewers).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@GerritConfig(name = "suggest.from", value = "2")
|
|
||||||
public void suggestReviewersNoResult3() throws Exception {
|
|
||||||
String changeId = createChange().getChangeId();
|
|
||||||
List<SuggestedReviewerInfo> reviewers =
|
|
||||||
suggestReviewers(changeId, name("").substring(0, 1), 6);
|
|
||||||
assertThat(reviewers).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void suggestReviewersChange() throws Exception {
|
public void suggestReviewersChange() throws Exception {
|
||||||
String changeId = createChange().getChangeId();
|
String changeId = createChange().getChangeId();
|
||||||
@@ -204,7 +202,7 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
|
|||||||
assertThat(reviewers).hasSize(1);
|
assertThat(reviewers).hasSize(1);
|
||||||
|
|
||||||
reviewers = suggestReviewers(changeId, "example.com", 7);
|
reviewers = suggestReviewers(changeId, "example.com", 7);
|
||||||
assertThat(reviewers).hasSize(6);
|
assertThat(reviewers).hasSize(5);
|
||||||
|
|
||||||
reviewers = suggestReviewers(changeId, user1.email, 2);
|
reviewers = suggestReviewers(changeId, user1.email, 2);
|
||||||
assertThat(reviewers).hasSize(1);
|
assertThat(reviewers).hasSize(1);
|
||||||
@@ -267,6 +265,145 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
|
|||||||
assertThat(reviewer.confirm).isTrue();
|
assertThat(reviewer.confirm).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void defaultReviewerSuggestion() throws Exception{
|
||||||
|
TestAccount user1 = user("customuser1", "User1");
|
||||||
|
TestAccount reviewer1 = user("customuser2", "User2");
|
||||||
|
TestAccount reviewer2 = user("customuser3", "User3");
|
||||||
|
|
||||||
|
setApiUser(user1);
|
||||||
|
String changeId1 = createChangeFromApi();
|
||||||
|
|
||||||
|
setApiUser(reviewer1);
|
||||||
|
reviewChange(changeId1);
|
||||||
|
|
||||||
|
setApiUser(user1);
|
||||||
|
String changeId2 = createChangeFromApi();
|
||||||
|
|
||||||
|
setApiUser(reviewer1);
|
||||||
|
reviewChange(changeId2);
|
||||||
|
|
||||||
|
setApiUser(reviewer2);
|
||||||
|
reviewChange(changeId2);
|
||||||
|
|
||||||
|
setApiUser(user1);
|
||||||
|
List<SuggestedReviewerInfo> reviewers =
|
||||||
|
suggestReviewers(createChangeFromApi(), null, 4);
|
||||||
|
assertThat(
|
||||||
|
reviewers.stream()
|
||||||
|
.map(r -> r.account._accountId)
|
||||||
|
.collect(Collectors.toList()))
|
||||||
|
.containsExactly(
|
||||||
|
reviewer1.id.get(),
|
||||||
|
reviewer2.id.get())
|
||||||
|
.inOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void defaultReviewerSuggestionOnFirstChange() throws Exception{
|
||||||
|
TestAccount user1 = user("customuser1", "User1");
|
||||||
|
setApiUser(user1);
|
||||||
|
List<SuggestedReviewerInfo> reviewers =
|
||||||
|
suggestReviewers(createChange().getChangeId(), "", 4);
|
||||||
|
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
|
||||||
|
setApiUser(userWhoOwns);
|
||||||
|
String changeId1 = createChangeFromApi();
|
||||||
|
|
||||||
|
setApiUser(reviewer1);
|
||||||
|
reviewChange(changeId1);
|
||||||
|
|
||||||
|
setApiUser(user1);
|
||||||
|
String changeId2 = createChangeFromApi();
|
||||||
|
|
||||||
|
setApiUser(reviewer1);
|
||||||
|
reviewChange(changeId2);
|
||||||
|
|
||||||
|
setApiUser(reviewer2);
|
||||||
|
reviewChange(changeId2);
|
||||||
|
|
||||||
|
// Create a comment as a different user
|
||||||
|
setApiUser(userWhoComments);
|
||||||
|
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
|
||||||
|
|
||||||
|
setApiUser(userWhoLooksForSuggestions);
|
||||||
|
List<SuggestedReviewerInfo> reviewers =
|
||||||
|
suggestReviewers(createChangeFromApi(), "Pri", 4);
|
||||||
|
assertThat(
|
||||||
|
reviewers.stream()
|
||||||
|
.map(r -> r.account._accountId)
|
||||||
|
.collect(Collectors.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 = createProject("test");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
setApiUser(userWhoOwns);
|
||||||
|
String changeId1 = createChangeFromApi();
|
||||||
|
|
||||||
|
setApiUser(reviewer1);
|
||||||
|
reviewChange(changeId1);
|
||||||
|
|
||||||
|
setApiUser(userWhoOwns);
|
||||||
|
String changeId2 = createChangeFromApi(newProject);
|
||||||
|
|
||||||
|
setApiUser(reviewer2);
|
||||||
|
reviewChange(changeId2);
|
||||||
|
|
||||||
|
setApiUser(userWhoOwns);
|
||||||
|
String changeId3 = createChangeFromApi(newProject);
|
||||||
|
|
||||||
|
setApiUser(reviewer2);
|
||||||
|
reviewChange(changeId3);
|
||||||
|
|
||||||
|
setApiUser(userWhoOwns);
|
||||||
|
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(Collectors.toList()))
|
||||||
|
.containsExactly(reviewer1.id.get(), reviewer2.id.get())
|
||||||
|
.inOrder();
|
||||||
|
}
|
||||||
|
|
||||||
private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
|
private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
|
||||||
String query, int n) throws Exception {
|
String query, int n) throws Exception {
|
||||||
return gApi.changes()
|
return gApi.changes()
|
||||||
@@ -296,4 +433,23 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
return user(name, fullName, name, groups);
|
return user(name, fullName, name, groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void reviewChange(String changeId) throws RestApiException {
|
||||||
|
ReviewInput ri = new ReviewInput();
|
||||||
|
ri.label("Code-Review", 1);
|
||||||
|
gApi.changes().id(changeId).current().review(ri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createChangeFromApi() throws RestApiException{
|
||||||
|
return createChangeFromApi(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createChangeFromApi(Project.NameKey project)
|
||||||
|
throws RestApiException{
|
||||||
|
ChangeInput ci = new ChangeInput();
|
||||||
|
ci.project = project.get();
|
||||||
|
ci.subject = "Test change at" + System.nanoTime();
|
||||||
|
ci.branch = "master";
|
||||||
|
return gApi.changes().create(ci).get().changeId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public abstract class HighlightSuggestOracle extends SuggestOracle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final void requestSuggestions(final Request request, final Callback cb) {
|
public final void requestSuggestions(Request request, Callback cb) {
|
||||||
onRequestSuggestions(request, new Callback() {
|
onRequestSuggestions(request, new Callback() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuggestionsReady(final Request request,
|
public void onSuggestionsReady(final Request request,
|
||||||
@@ -88,27 +88,28 @@ public abstract class HighlightSuggestOracle extends SuggestOracle {
|
|||||||
ds = escape(ds);
|
ds = escape(ds);
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder pattern = new StringBuilder();
|
if (qstr != null && !qstr.isEmpty()) {
|
||||||
for (String qterm : splitQuery(qstr)) {
|
StringBuilder pattern = new StringBuilder();
|
||||||
qterm = escape(qterm);
|
for (String qterm : splitQuery(qstr)) {
|
||||||
// We now surround qstr by <strong>. But the chosen approach is not too
|
qterm = escape(qterm);
|
||||||
// smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
|
// We now surround qstr by <strong>. But the chosen approach is not too
|
||||||
// escapes (e.g.: "Tim <email@example.org>"). Those escapes will
|
// smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
|
||||||
// get <strong>-ed as well (e.g.: "<" -> "&<strong>l</strong>t;"). But
|
// escapes (e.g.: "Tim <email@example.org>"). Those escapes will
|
||||||
// as repairing those mangled escapes is easier than not mangling them in
|
// get <strong>-ed as well (e.g.: "<" -> "&<strong>l</strong>t;"). But
|
||||||
// the first place, we repair them afterwards.
|
// as repairing those mangled escapes is easier than not mangling them in
|
||||||
|
// the first place, we repair them afterwards.
|
||||||
if (pattern.length() > 0) {
|
if (pattern.length() > 0) {
|
||||||
pattern.append("|");
|
pattern.append("|");
|
||||||
|
}
|
||||||
|
pattern.append(qterm);
|
||||||
}
|
}
|
||||||
pattern.append(qterm);
|
|
||||||
|
ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
|
||||||
|
|
||||||
|
// Repairing <strong>-ed escapes.
|
||||||
|
ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
|
||||||
}
|
}
|
||||||
|
|
||||||
ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
|
|
||||||
|
|
||||||
// Repairing <strong>-ed escapes.
|
|
||||||
ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
|
|
||||||
|
|
||||||
displayString = ds;
|
displayString = ds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
package com.google.gerrit.client.ui;
|
package com.google.gerrit.client.ui;
|
||||||
|
|
||||||
|
import com.google.gwt.user.client.Timer;
|
||||||
import com.google.gwt.user.client.ui.SuggestOracle;
|
import com.google.gwt.user.client.ui.SuggestOracle;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,10 @@ public class RemoteSuggestOracle extends SuggestOracle {
|
|||||||
private final SuggestOracle oracle;
|
private final SuggestOracle oracle;
|
||||||
private Query query;
|
private Query query;
|
||||||
private String last;
|
private String last;
|
||||||
|
private Timer requestRetentionTimer;
|
||||||
|
private boolean cancelOutstandingRequest;
|
||||||
|
|
||||||
|
private boolean serveSuggestions;
|
||||||
|
|
||||||
public RemoteSuggestOracle(SuggestOracle src) {
|
public RemoteSuggestOracle(SuggestOracle src) {
|
||||||
oracle = src;
|
oracle = src;
|
||||||
@@ -42,13 +47,33 @@ public class RemoteSuggestOracle extends SuggestOracle {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void requestSuggestions(Request req, Callback cb) {
|
public void requestSuggestions(Request req, Callback cb) {
|
||||||
Query q = new Query(req, cb);
|
if (!serveSuggestions){
|
||||||
if (query == null) {
|
return;
|
||||||
query = q;
|
|
||||||
q.start();
|
|
||||||
} else {
|
|
||||||
query = q;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a timer for key stroke retention, such that we don't query the
|
||||||
|
// backend for each and every keystroke we receive.
|
||||||
|
if (requestRetentionTimer != null) {
|
||||||
|
requestRetentionTimer.cancel();
|
||||||
|
}
|
||||||
|
requestRetentionTimer = new Timer() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Query q = new Query(req, cb);
|
||||||
|
if (query == null) {
|
||||||
|
query = q;
|
||||||
|
q.start();
|
||||||
|
} else {
|
||||||
|
query = q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestRetentionTimer.schedule(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void requestDefaultSuggestions(Request req, Callback cb) {
|
||||||
|
requestSuggestions(req, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -56,6 +81,19 @@ public class RemoteSuggestOracle extends SuggestOracle {
|
|||||||
return oracle.isDisplayStringHTML();
|
return oracle.isDisplayStringHTML();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void cancelOutstandingRequest() {
|
||||||
|
if (requestRetentionTimer != null) {
|
||||||
|
requestRetentionTimer.cancel();
|
||||||
|
}
|
||||||
|
if (query != null) {
|
||||||
|
cancelOutstandingRequest = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServeSuggestions(boolean serveSuggestions) {
|
||||||
|
this.serveSuggestions = serveSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
private class Query implements Callback {
|
private class Query implements Callback {
|
||||||
final Request request;
|
final Request request;
|
||||||
final Callback callback;
|
final Callback callback;
|
||||||
@@ -71,7 +109,11 @@ public class RemoteSuggestOracle extends SuggestOracle {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSuggestionsReady(Request req, Response res) {
|
public void onSuggestionsReady(Request req, Response res) {
|
||||||
if (query == this) {
|
if (cancelOutstandingRequest || !serveSuggestions) {
|
||||||
|
// If cancelOutstandingRequest() was called, we ignore this response
|
||||||
|
cancelOutstandingRequest = false;
|
||||||
|
query = null;
|
||||||
|
} else if (query == this) {
|
||||||
// No new request was started while this query was running.
|
// No new request was started while this query was running.
|
||||||
// Propose this request's response as the suggestions.
|
// Propose this request's response as the suggestions.
|
||||||
query = null;
|
query = null;
|
||||||
|
|||||||
@@ -21,21 +21,21 @@ import com.google.gerrit.client.info.GroupBaseInfo;
|
|||||||
import com.google.gerrit.client.rpc.GerritCallback;
|
import com.google.gerrit.client.rpc.GerritCallback;
|
||||||
import com.google.gerrit.client.rpc.Natives;
|
import com.google.gerrit.client.rpc.Natives;
|
||||||
import com.google.gerrit.client.ui.AccountSuggestOracle;
|
import com.google.gerrit.client.ui.AccountSuggestOracle;
|
||||||
import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
|
|
||||||
import com.google.gerrit.reviewdb.client.Change;
|
import com.google.gerrit.reviewdb.client.Change;
|
||||||
import com.google.gwt.core.client.JavaScriptObject;
|
import com.google.gwt.core.client.JavaScriptObject;
|
||||||
import com.google.gwt.core.client.JsArray;
|
import com.google.gwt.core.client.JsArray;
|
||||||
|
import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** REST API based suggestion Oracle for reviewers. */
|
/** REST API based suggestion Oracle for reviewers. */
|
||||||
public class ReviewerSuggestOracle extends SuggestAfterTypingNCharsOracle {
|
public class ReviewerSuggestOracle extends HighlightSuggestOracle {
|
||||||
private Change.Id changeId;
|
private Change.Id changeId;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void _onRequestSuggestions(final Request req, final Callback cb) {
|
protected void onRequestSuggestions(final Request req, final Callback cb) {
|
||||||
ChangeApi
|
ChangeApi
|
||||||
.suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), false)
|
.suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), false)
|
||||||
.get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
|
.get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
|
||||||
@@ -56,6 +56,11 @@ public class ReviewerSuggestOracle extends SuggestAfterTypingNCharsOracle {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void requestDefaultSuggestions(final Request req, final Callback cb) {
|
||||||
|
requestSuggestions(req, cb);
|
||||||
|
}
|
||||||
|
|
||||||
public void setChange(Change.Id changeId) {
|
public void setChange(Change.Id changeId) {
|
||||||
this.changeId = changeId;
|
this.changeId = changeId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ public class Reviewers extends Composite {
|
|||||||
Reviewers() {
|
Reviewers() {
|
||||||
reviewerSuggestOracle = new ReviewerSuggestOracle();
|
reviewerSuggestOracle = new ReviewerSuggestOracle();
|
||||||
suggestBox = new RemoteSuggestBox(reviewerSuggestOracle);
|
suggestBox = new RemoteSuggestBox(reviewerSuggestOracle);
|
||||||
|
suggestBox.enableDefaultSuggestions();
|
||||||
suggestBox.setVisibleLength(55);
|
suggestBox.setVisibleLength(55);
|
||||||
suggestBox.setHintText(Util.C.approvalTableAddReviewerHint());
|
suggestBox.setHintText(Util.C.approvalTableAddReviewerHint());
|
||||||
suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
|
suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
|
||||||
@@ -123,6 +124,7 @@ public class Reviewers extends Composite {
|
|||||||
UIObject.setVisible(form, true);
|
UIObject.setVisible(form, true);
|
||||||
UIObject.setVisible(error, false);
|
UIObject.setVisible(error, false);
|
||||||
addReviewerIcon.setVisible(false);
|
addReviewerIcon.setVisible(false);
|
||||||
|
suggestBox.setServeSuggestionsOnOracle(true);
|
||||||
suggestBox.setFocus(true);
|
suggestBox.setFocus(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +145,7 @@ public class Reviewers extends Composite {
|
|||||||
UIObject.setVisible(form, false);
|
UIObject.setVisible(form, false);
|
||||||
suggestBox.setFocus(false);
|
suggestBox.setFocus(false);
|
||||||
suggestBox.setText("");
|
suggestBox.setText("");
|
||||||
|
suggestBox.setServeSuggestionsOnOracle(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addReviewer(final String reviewer, boolean confirmed) {
|
private void addReviewer(final String reviewer, boolean confirmed) {
|
||||||
|
|||||||
@@ -171,10 +171,13 @@ public class ChangeApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static RestApi suggestReviewers(int id, String q, int n, boolean e) {
|
public static RestApi suggestReviewers(int id, String q, int n, boolean e) {
|
||||||
return change(id).view("suggest_reviewers")
|
RestApi api = change(id).view("suggest_reviewers")
|
||||||
.addParameter("q", q)
|
|
||||||
.addParameter("n", n)
|
.addParameter("n", n)
|
||||||
.addParameter("e", e);
|
.addParameter("e", e);
|
||||||
|
if (q != null) {
|
||||||
|
api.addParameter("q", q);
|
||||||
|
}
|
||||||
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static RestApi vote(int id, int reviewer, String vote) {
|
public static RestApi vote(int id, int reviewer, String vote) {
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ public class AccountSuggestOracle extends SuggestAfterTypingNCharsOracle {
|
|||||||
|
|
||||||
public static String format(AccountInfo info, String query) {
|
public static String format(AccountInfo info, String query) {
|
||||||
String s = FormatUtil.nameEmail(info);
|
String s = FormatUtil.nameEmail(info);
|
||||||
if (!containsQuery(s, query) && info.secondaryEmails() != null) {
|
if (query != null && !containsQuery(s, query) &&
|
||||||
|
info.secondaryEmails() != null) {
|
||||||
for (String email : Natives.asList(info.secondaryEmails())) {
|
for (String email : Natives.asList(info.secondaryEmails())) {
|
||||||
AccountInfo info2 = AccountInfo.create(info._accountId(), info.name(),
|
AccountInfo info2 = AccountInfo.create(info._accountId(), info.name(),
|
||||||
email, info.username());
|
email, info.username());
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
package com.google.gerrit.client.ui;
|
package com.google.gerrit.client.ui;
|
||||||
|
|
||||||
|
import com.google.gwt.event.dom.client.FocusEvent;
|
||||||
|
import com.google.gwt.event.dom.client.FocusHandler;
|
||||||
import com.google.gwt.event.dom.client.KeyCodes;
|
import com.google.gwt.event.dom.client.KeyCodes;
|
||||||
import com.google.gwt.event.dom.client.KeyDownEvent;
|
import com.google.gwt.event.dom.client.KeyDownEvent;
|
||||||
import com.google.gwt.event.dom.client.KeyDownHandler;
|
import com.google.gwt.event.dom.client.KeyDownHandler;
|
||||||
@@ -42,6 +44,7 @@ public class RemoteSuggestBox extends Composite implements Focusable, HasText,
|
|||||||
|
|
||||||
public RemoteSuggestBox(SuggestOracle oracle) {
|
public RemoteSuggestBox(SuggestOracle oracle) {
|
||||||
remoteSuggestOracle = new RemoteSuggestOracle(oracle);
|
remoteSuggestOracle = new RemoteSuggestOracle(oracle);
|
||||||
|
remoteSuggestOracle.setServeSuggestions(true);
|
||||||
display = new DefaultSuggestionDisplay();
|
display = new DefaultSuggestionDisplay();
|
||||||
|
|
||||||
textBox = new HintTextBox();
|
textBox = new HintTextBox();
|
||||||
@@ -49,7 +52,6 @@ public class RemoteSuggestBox extends Composite implements Focusable, HasText,
|
|||||||
@Override
|
@Override
|
||||||
public void onKeyDown(KeyDownEvent e) {
|
public void onKeyDown(KeyDownEvent e) {
|
||||||
submitOnSelection = false;
|
submitOnSelection = false;
|
||||||
|
|
||||||
if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
|
if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
|
||||||
CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
|
CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
|
||||||
} else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
|
} else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
|
||||||
@@ -70,10 +72,11 @@ public class RemoteSuggestBox extends Composite implements Focusable, HasText,
|
|||||||
suggestBox.addSelectionHandler(new SelectionHandler<Suggestion>() {
|
suggestBox.addSelectionHandler(new SelectionHandler<Suggestion>() {
|
||||||
@Override
|
@Override
|
||||||
public void onSelection(SelectionEvent<Suggestion> event) {
|
public void onSelection(SelectionEvent<Suggestion> event) {
|
||||||
textBox.setFocus(true);
|
|
||||||
if (submitOnSelection) {
|
if (submitOnSelection) {
|
||||||
SelectionEvent.fire(RemoteSuggestBox.this, getText());
|
SelectionEvent.fire(RemoteSuggestBox.this, getText());
|
||||||
}
|
}
|
||||||
|
remoteSuggestOracle.cancelOutstandingRequest();
|
||||||
|
display.hideSuggestions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
initWidget(suggestBox);
|
initWidget(suggestBox);
|
||||||
@@ -138,4 +141,19 @@ public class RemoteSuggestBox extends Composite implements Focusable, HasText,
|
|||||||
public void selectAll() {
|
public void selectAll() {
|
||||||
suggestBox.getValueBox().selectAll();
|
suggestBox.getValueBox().selectAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void enableDefaultSuggestions() {
|
||||||
|
textBox.addFocusHandler(new FocusHandler() {
|
||||||
|
@Override
|
||||||
|
public void onFocus(FocusEvent focusEvent) {
|
||||||
|
if (textBox.getText().equals("")) {
|
||||||
|
suggestBox.showSuggestionList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServeSuggestionsOnOracle(boolean serveSuggestions) {
|
||||||
|
remoteSuggestOracle.setServeSuggestions(serveSuggestions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import com.google.gerrit.common.data.LabelType;
|
||||||
|
import com.google.gerrit.extensions.registration.DynamicMap;
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
import com.google.gerrit.reviewdb.client.PatchSetApproval;
|
||||||
|
import com.google.gerrit.server.account.AccountDirectory.FillOptions;
|
||||||
|
import com.google.gerrit.server.account.AccountLoader;
|
||||||
|
import com.google.gerrit.server.change.ReviewerSuggestion;
|
||||||
|
import com.google.gerrit.server.change.SuggestReviewers;
|
||||||
|
import com.google.gerrit.server.change.SuggestedReviewer;
|
||||||
|
import com.google.gerrit.server.config.GerritServerConfig;
|
||||||
|
import com.google.gerrit.server.git.WorkQueue;
|
||||||
|
import com.google.gerrit.server.index.change.ChangeField;
|
||||||
|
import com.google.gerrit.server.notedb.ChangeNotes;
|
||||||
|
import com.google.gerrit.server.project.ProjectControl;
|
||||||
|
import com.google.gerrit.server.query.Predicate;
|
||||||
|
import com.google.gerrit.server.query.QueryParseException;
|
||||||
|
import com.google.gerrit.server.query.change.ChangeData;
|
||||||
|
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
|
||||||
|
import com.google.gerrit.server.query.change.InternalChangeQuery;
|
||||||
|
import com.google.gwtorm.server.OrmException;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
|
||||||
|
import org.apache.commons.lang.mutable.MutableDouble;
|
||||||
|
import org.eclipse.jgit.lib.Config;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Map.Entry;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
public class ReviewerRecommender {
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(ReviewersUtil.class);
|
||||||
|
private static final double BASE_REVIEWER_WEIGHT = 10;
|
||||||
|
private static final double BASE_OWNER_WEIGHT = 1;
|
||||||
|
private static final double BASE_COMMENT_WEIGHT = 0.5;
|
||||||
|
private static final long PLUGIN_QUERY_TIMEOUT = 500; //ms
|
||||||
|
|
||||||
|
private final ChangeQueryBuilder changeQueryBuilder;
|
||||||
|
private final Config config;
|
||||||
|
private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
|
||||||
|
private final InternalChangeQuery internalChangeQuery;
|
||||||
|
private final WorkQueue workQueue;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ReviewerRecommender(ChangeQueryBuilder changeQueryBuilder,
|
||||||
|
DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
|
||||||
|
InternalChangeQuery internalChangeQuery,
|
||||||
|
WorkQueue workQueue,
|
||||||
|
@GerritServerConfig Config config) {
|
||||||
|
Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
|
||||||
|
fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
|
||||||
|
this.changeQueryBuilder = changeQueryBuilder;
|
||||||
|
this.config = config;
|
||||||
|
this.internalChangeQuery = internalChangeQuery;
|
||||||
|
this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
|
||||||
|
this.workQueue = workQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Account.Id> suggestReviewers(
|
||||||
|
ChangeNotes changeNotes,
|
||||||
|
SuggestReviewers suggestReviewers, ProjectControl projectControl,
|
||||||
|
List<Account.Id> candidateList)
|
||||||
|
throws OrmException {
|
||||||
|
String query = suggestReviewers.getQuery();
|
||||||
|
double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
|
||||||
|
|
||||||
|
Map<Account.Id, MutableDouble> reviewerScores;
|
||||||
|
if (Strings.isNullOrEmpty(query)) {
|
||||||
|
reviewerScores = baseRankingForEmptyQuery(baseWeight);
|
||||||
|
} else {
|
||||||
|
reviewerScores = baseRankingForCandidateList(
|
||||||
|
candidateList, projectControl, baseWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the query along with a candidate list to all plugins and merge the
|
||||||
|
// results. Plugins don't necessarily need to use the candidates list, they
|
||||||
|
// can also return non-candidate account ids.
|
||||||
|
List<Callable<Set<SuggestedReviewer>>> tasks =
|
||||||
|
new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
|
||||||
|
List<Double> weights =
|
||||||
|
new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
|
||||||
|
|
||||||
|
for (DynamicMap.Entry<ReviewerSuggestion> plugin :
|
||||||
|
reviewerSuggestionPluginMap) {
|
||||||
|
tasks.add(() -> plugin.getProvider().get().suggestReviewers(
|
||||||
|
changeNotes.getChangeId(), query, reviewerScores.keySet()));
|
||||||
|
String pluginWeight = config.getString("addReviewer",
|
||||||
|
plugin.getPluginName() + "-" + plugin.getExportName(), "weight");
|
||||||
|
if (Strings.isNullOrEmpty(pluginWeight)) {
|
||||||
|
pluginWeight = "1";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
weights.add(Double.parseDouble(pluginWeight));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.error("Exception while parsing weight for " +
|
||||||
|
plugin.getPluginName() + "-" + plugin.getExportName(), e);
|
||||||
|
weights.add(1d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Future<Set<SuggestedReviewer>>> futures = workQueue
|
||||||
|
.getDefaultQueue()
|
||||||
|
.invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
|
||||||
|
Iterator<Double> weightIterator = weights.iterator();
|
||||||
|
for (Future<Set<SuggestedReviewer>> f : futures) {
|
||||||
|
double weight = weightIterator.next();
|
||||||
|
for (SuggestedReviewer s : f.get()) {
|
||||||
|
if (reviewerScores.containsKey(s.account)) {
|
||||||
|
reviewerScores.get(s.account).add(s.score * weight);
|
||||||
|
} else {
|
||||||
|
reviewerScores.put(s.account, new MutableDouble(s.score * weight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
|
log.error("Exception while suggesting reviewers", e);
|
||||||
|
return ImmutableList.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove change owner
|
||||||
|
reviewerScores.remove(changeNotes.getChange().getOwner());
|
||||||
|
|
||||||
|
// Sort results
|
||||||
|
Stream<Entry<Account.Id, MutableDouble>> sorted =
|
||||||
|
reviewerScores.entrySet().stream()
|
||||||
|
.sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
|
||||||
|
List<Account.Id> sortedSuggestions = sorted
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return sortedSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(
|
||||||
|
double baseWeight) throws OrmException{
|
||||||
|
// Get the user's last 50 changes, check approvals
|
||||||
|
try {
|
||||||
|
List<ChangeData> result = internalChangeQuery
|
||||||
|
.setLimit(50)
|
||||||
|
.setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName()))
|
||||||
|
.query(changeQueryBuilder.owner("self"));
|
||||||
|
Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
|
||||||
|
for (ChangeData cd : result) {
|
||||||
|
for (PatchSetApproval approval : cd.currentApprovals()) {
|
||||||
|
Account.Id id = approval.getAccountId();
|
||||||
|
if (suggestions.containsKey(id)) {
|
||||||
|
suggestions.get(id).add(baseWeight);
|
||||||
|
} else {
|
||||||
|
suggestions.put(id, new MutableDouble(baseWeight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return suggestions;
|
||||||
|
} catch (QueryParseException e) {
|
||||||
|
// Unhandled, because owner:self will never provoke a QueryParseException
|
||||||
|
log.error("Exception while suggesting reviewers", e);
|
||||||
|
return ImmutableMap.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
|
||||||
|
List<Account.Id> candidates,
|
||||||
|
ProjectControl projectControl,
|
||||||
|
double baseWeight) throws OrmException {
|
||||||
|
// 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(projectControl.getProject().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 =
|
||||||
|
projectControl.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
|
||||||
|
log.error("Exception while suggesting reviewers", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<ChangeData>> result = internalChangeQuery
|
||||||
|
.setLimit(100 * predicates.size())
|
||||||
|
.setRequestedFields(ImmutableSet.of())
|
||||||
|
.query(predicates);
|
||||||
|
|
||||||
|
Iterator<List<ChangeData>> queryResultIterator = result.iterator();
|
||||||
|
Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
|
||||||
|
|
||||||
|
double[] weights = new double[]{
|
||||||
|
BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,20 +14,15 @@
|
|||||||
|
|
||||||
package com.google.gerrit.server;
|
package com.google.gerrit.server;
|
||||||
|
|
||||||
import static java.util.Comparator.comparing;
|
|
||||||
|
|
||||||
import com.google.common.base.MoreObjects;
|
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.collect.Iterables;
|
import com.google.common.collect.Iterables;
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
import com.google.common.collect.Ordering;
|
|
||||||
import com.google.gerrit.common.data.GroupReference;
|
import com.google.gerrit.common.data.GroupReference;
|
||||||
import com.google.gerrit.common.errors.NoSuchGroupException;
|
import com.google.gerrit.common.errors.NoSuchGroupException;
|
||||||
import com.google.gerrit.extensions.common.AccountInfo;
|
import com.google.gerrit.extensions.common.AccountInfo;
|
||||||
import com.google.gerrit.extensions.common.GroupBaseInfo;
|
import com.google.gerrit.extensions.common.GroupBaseInfo;
|
||||||
import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
|
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.extensions.restapi.Url;
|
||||||
import com.google.gerrit.reviewdb.client.Account;
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
import com.google.gerrit.reviewdb.client.AccountExternalId;
|
import com.google.gerrit.reviewdb.client.AccountExternalId;
|
||||||
@@ -44,6 +39,7 @@ import com.google.gerrit.server.change.PostReviewers;
|
|||||||
import com.google.gerrit.server.change.SuggestReviewers;
|
import com.google.gerrit.server.change.SuggestReviewers;
|
||||||
import com.google.gerrit.server.index.account.AccountIndex;
|
import com.google.gerrit.server.index.account.AccountIndex;
|
||||||
import com.google.gerrit.server.index.account.AccountIndexCollection;
|
import com.google.gerrit.server.index.account.AccountIndexCollection;
|
||||||
|
import com.google.gerrit.server.notedb.ChangeNotes;
|
||||||
import com.google.gerrit.server.project.NoSuchProjectException;
|
import com.google.gerrit.server.project.NoSuchProjectException;
|
||||||
import com.google.gerrit.server.project.ProjectControl;
|
import com.google.gerrit.server.project.ProjectControl;
|
||||||
import com.google.gerrit.server.query.QueryParseException;
|
import com.google.gerrit.server.query.QueryParseException;
|
||||||
@@ -56,98 +52,95 @@ import com.google.inject.Provider;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.HashMap;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public class ReviewersUtil {
|
public class ReviewersUtil {
|
||||||
private static final String MAX_SUFFIX = "\u9fa5";
|
private static final String MAX_SUFFIX = "\u9fa5";
|
||||||
private static final Ordering<SuggestedReviewerInfo> ORDERING =
|
// Generate a candidate list at 3x the size of what the user wants to see to
|
||||||
Ordering.<SuggestedReviewerInfo> from(comparing(
|
// give the ranking algorithm a good set of candidates it can work with
|
||||||
suggestedReviewerInfo -> {
|
private static final int CANDIDATE_LIST_MULTIPLIER = 3;
|
||||||
if (suggestedReviewerInfo == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return suggestedReviewerInfo.account != null
|
|
||||||
? MoreObjects.firstNonNull(suggestedReviewerInfo.account.email,
|
|
||||||
Strings.nullToEmpty(suggestedReviewerInfo.account.name))
|
|
||||||
: Strings.nullToEmpty(suggestedReviewerInfo.group.name);
|
|
||||||
}));
|
|
||||||
|
|
||||||
private final AccountLoader accountLoader;
|
|
||||||
private final AccountCache accountCache;
|
private final AccountCache accountCache;
|
||||||
private final AccountIndexCollection indexes;
|
|
||||||
private final AccountQueryBuilder queryBuilder;
|
|
||||||
private final AccountQueryProcessor queryProcessor;
|
|
||||||
private final AccountControl accountControl;
|
private final AccountControl accountControl;
|
||||||
private final Provider<ReviewDb> dbProvider;
|
private final AccountIndexCollection accountIndexes;
|
||||||
|
private final AccountLoader accountLoader;
|
||||||
|
private final AccountQueryBuilder accountQueryBuilder;
|
||||||
|
private final AccountQueryProcessor accountQueryProcessor;
|
||||||
private final GroupBackend groupBackend;
|
private final GroupBackend groupBackend;
|
||||||
private final GroupMembers.Factory groupMembersFactory;
|
private final GroupMembers.Factory groupMembersFactory;
|
||||||
private final Provider<CurrentUser> currentUser;
|
private final Provider<CurrentUser> currentUser;
|
||||||
|
private final Provider<ReviewDb> dbProvider;
|
||||||
|
private final ReviewerRecommender reviewerRecommender;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
ReviewersUtil(AccountLoader.Factory accountLoaderFactory,
|
ReviewersUtil(AccountCache accountCache,
|
||||||
AccountCache accountCache,
|
|
||||||
AccountIndexCollection indexes,
|
|
||||||
AccountQueryBuilder queryBuilder,
|
|
||||||
AccountQueryProcessor queryProcessor,
|
|
||||||
AccountControl.Factory accountControlFactory,
|
AccountControl.Factory accountControlFactory,
|
||||||
Provider<ReviewDb> dbProvider,
|
AccountIndexCollection accountIndexes,
|
||||||
|
AccountLoader.Factory accountLoaderFactory,
|
||||||
|
AccountQueryBuilder accountQueryBuilder,
|
||||||
|
AccountQueryProcessor accountQueryProcessor,
|
||||||
GroupBackend groupBackend,
|
GroupBackend groupBackend,
|
||||||
GroupMembers.Factory groupMembersFactory,
|
GroupMembers.Factory groupMembersFactory,
|
||||||
Provider<CurrentUser> currentUser) {
|
Provider<CurrentUser> currentUser,
|
||||||
|
Provider<ReviewDb> dbProvider,
|
||||||
|
ReviewerRecommender reviewerRecommender) {
|
||||||
Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
|
Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
|
||||||
fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
|
fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
|
||||||
this.accountLoader = accountLoaderFactory.create(fillOptions);
|
|
||||||
this.accountCache = accountCache;
|
this.accountCache = accountCache;
|
||||||
this.indexes = indexes;
|
|
||||||
this.queryBuilder = queryBuilder;
|
|
||||||
this.queryProcessor = queryProcessor;
|
|
||||||
this.accountControl = accountControlFactory.get();
|
this.accountControl = accountControlFactory.get();
|
||||||
|
this.accountIndexes = accountIndexes;
|
||||||
|
this.accountLoader = accountLoaderFactory.create(fillOptions);
|
||||||
|
this.accountQueryBuilder = accountQueryBuilder;
|
||||||
|
this.accountQueryProcessor = accountQueryProcessor;
|
||||||
|
this.currentUser = currentUser;
|
||||||
this.dbProvider = dbProvider;
|
this.dbProvider = dbProvider;
|
||||||
this.groupBackend = groupBackend;
|
this.groupBackend = groupBackend;
|
||||||
this.groupMembersFactory = groupMembersFactory;
|
this.groupMembersFactory = groupMembersFactory;
|
||||||
this.currentUser = currentUser;
|
this.reviewerRecommender = reviewerRecommender;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface VisibilityControl {
|
public interface VisibilityControl {
|
||||||
boolean isVisibleTo(Account.Id account) throws OrmException;
|
boolean isVisibleTo(Account.Id account) throws OrmException;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SuggestedReviewerInfo> suggestReviewers(
|
public List<SuggestedReviewerInfo> suggestReviewers(ChangeNotes changeNotes,
|
||||||
SuggestReviewers suggestReviewers, ProjectControl projectControl,
|
SuggestReviewers suggestReviewers, ProjectControl projectControl,
|
||||||
VisibilityControl visibilityControl, boolean excludeGroups)
|
VisibilityControl visibilityControl, boolean excludeGroups)
|
||||||
throws IOException, OrmException, BadRequestException {
|
throws IOException, OrmException {
|
||||||
String query = suggestReviewers.getQuery();
|
String query = suggestReviewers.getQuery();
|
||||||
boolean suggestAccounts = suggestReviewers.getSuggestAccounts();
|
|
||||||
int suggestFrom = suggestReviewers.getSuggestFrom();
|
|
||||||
int limit = suggestReviewers.getLimit();
|
int limit = suggestReviewers.getLimit();
|
||||||
|
|
||||||
if (Strings.isNullOrEmpty(query)) {
|
if (!suggestReviewers.getSuggestAccounts()) {
|
||||||
throw new BadRequestException("missing query field");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!suggestAccounts || query.length() < suggestFrom) {
|
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Collection<AccountInfo> suggestedAccounts =
|
List<Account.Id> candidateList = new ArrayList<>();
|
||||||
suggestAccounts(suggestReviewers, visibilityControl);
|
if (!Strings.isNullOrEmpty(query)) {
|
||||||
|
candidateList = suggestAccounts(suggestReviewers, visibilityControl);
|
||||||
List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
|
|
||||||
for (AccountInfo a : suggestedAccounts) {
|
|
||||||
SuggestedReviewerInfo info = new SuggestedReviewerInfo();
|
|
||||||
info.account = a;
|
|
||||||
info.count = 1;
|
|
||||||
reviewer.add(info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!excludeGroups) {
|
List<Account.Id> sortedRecommendations = reviewerRecommender
|
||||||
|
.suggestReviewers(changeNotes, suggestReviewers, projectControl,
|
||||||
|
candidateList);
|
||||||
|
|
||||||
|
// Populate AccountInfo
|
||||||
|
List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
|
||||||
|
for (Account.Id id : sortedRecommendations) {
|
||||||
|
AccountInfo account = accountLoader.get(id);
|
||||||
|
if (account != null) {
|
||||||
|
SuggestedReviewerInfo info = new SuggestedReviewerInfo();
|
||||||
|
info.account = account;
|
||||||
|
info.count = 1;
|
||||||
|
reviewer.add(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountLoader.fill();
|
||||||
|
|
||||||
|
if (!excludeGroups && !Strings.isNullOrEmpty(query)) {
|
||||||
for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
|
for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
|
||||||
GroupAsReviewer result = suggestGroupAsReviewer(
|
GroupAsReviewer result = suggestGroupAsReviewer(
|
||||||
suggestReviewers, projectControl.getProject(), g, visibilityControl);
|
suggestReviewers, projectControl.getProject(), g, visibilityControl);
|
||||||
@@ -161,59 +154,56 @@ public class ReviewersUtil {
|
|||||||
if (result.allowedWithConfirmation) {
|
if (result.allowedWithConfirmation) {
|
||||||
suggestedReviewerInfo.confirm = true;
|
suggestedReviewerInfo.confirm = true;
|
||||||
}
|
}
|
||||||
|
// Always add groups at the end as individual accounts are usually
|
||||||
|
// more important
|
||||||
reviewer.add(suggestedReviewerInfo);
|
reviewer.add(suggestedReviewerInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reviewer = ORDERING.immutableSortedCopy(reviewer);
|
|
||||||
if (reviewer.size() <= limit) {
|
if (reviewer.size() <= limit) {
|
||||||
return reviewer;
|
return reviewer;
|
||||||
}
|
}
|
||||||
return reviewer.subList(0, limit);
|
return reviewer.subList(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Collection<AccountInfo> suggestAccounts(SuggestReviewers suggestReviewers,
|
private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers,
|
||||||
VisibilityControl visibilityControl)
|
VisibilityControl visibilityControl)
|
||||||
throws OrmException {
|
throws OrmException {
|
||||||
AccountIndex searchIndex = indexes.getSearchIndex();
|
AccountIndex searchIndex = accountIndexes.getSearchIndex();
|
||||||
if (searchIndex != null) {
|
if (searchIndex != null) {
|
||||||
return suggestAccountsFromIndex(suggestReviewers);
|
return suggestAccountsFromIndex(suggestReviewers);
|
||||||
}
|
}
|
||||||
return suggestAccountsFromDb(suggestReviewers, visibilityControl);
|
return suggestAccountsFromDb(suggestReviewers, visibilityControl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Collection<AccountInfo> suggestAccountsFromIndex(
|
private List<Account.Id> suggestAccountsFromIndex(
|
||||||
SuggestReviewers suggestReviewers) throws OrmException {
|
SuggestReviewers suggestReviewers) throws OrmException {
|
||||||
try {
|
try {
|
||||||
Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
|
Set<Account.Id> matches = new HashSet<>();
|
||||||
QueryResult<AccountState> result = queryProcessor
|
QueryResult<AccountState> result = accountQueryProcessor
|
||||||
.setLimit(suggestReviewers.getLimit())
|
.setLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
|
||||||
.query(queryBuilder.defaultQuery(suggestReviewers.getQuery()));
|
.query(accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
|
||||||
for (AccountState accountState : result.entities()) {
|
for (AccountState accountState : result.entities()) {
|
||||||
Account.Id id = accountState.getAccount().getId();
|
Account.Id id = accountState.getAccount().getId();
|
||||||
matches.put(id, accountLoader.get(id));
|
matches.add(id);
|
||||||
}
|
}
|
||||||
|
return new ArrayList<>(matches);
|
||||||
accountLoader.fill();
|
|
||||||
|
|
||||||
return matches.values();
|
|
||||||
} catch (QueryParseException e) {
|
} catch (QueryParseException e) {
|
||||||
return ImmutableList.of();
|
return ImmutableList.of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Collection<AccountInfo> suggestAccountsFromDb(
|
private List<Account.Id> suggestAccountsFromDb(
|
||||||
SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
|
SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
|
||||||
throws OrmException {
|
throws OrmException {
|
||||||
String query = suggestReviewers.getQuery();
|
String query = suggestReviewers.getQuery();
|
||||||
int limit = suggestReviewers.getLimit();
|
int limit = suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER;
|
||||||
|
|
||||||
String a = query;
|
String a = query;
|
||||||
String b = a + MAX_SUFFIX;
|
String b = a + MAX_SUFFIX;
|
||||||
|
|
||||||
Map<Account.Id, AccountInfo> r = new LinkedHashMap<>();
|
Set<Account.Id> r = new HashSet<>();
|
||||||
Map<Account.Id, String> queryEmail = new HashMap<>();
|
|
||||||
|
|
||||||
for (Account p : dbProvider.get().accounts()
|
for (Account p : dbProvider.get().accounts()
|
||||||
.suggestByFullName(a, b, limit)) {
|
.suggestByFullName(a, b, limit)) {
|
||||||
@@ -234,36 +224,26 @@ public class ReviewersUtil {
|
|||||||
if (r.size() < limit) {
|
if (r.size() < limit) {
|
||||||
for (AccountExternalId e : dbProvider.get().accountExternalIds()
|
for (AccountExternalId e : dbProvider.get().accountExternalIds()
|
||||||
.suggestByEmailAddress(a, b, limit - r.size())) {
|
.suggestByEmailAddress(a, b, limit - r.size())) {
|
||||||
if (!r.containsKey(e.getAccountId())) {
|
if (!r.contains(e.getAccountId())) {
|
||||||
Account p = accountCache.get(e.getAccountId()).getAccount();
|
Account p = accountCache.get(e.getAccountId()).getAccount();
|
||||||
if (p.isActive()) {
|
if (p.isActive()) {
|
||||||
if (addSuggestion(r, p.getId(), visibilityControl)) {
|
addSuggestion(r, p.getId(), visibilityControl);
|
||||||
queryEmail.put(e.getAccountId(), e.getEmailAddress());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return new ArrayList<>(r);
|
||||||
accountLoader.fill();
|
|
||||||
for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
|
|
||||||
AccountInfo info = r.get(p.getKey());
|
|
||||||
if (info != null) {
|
|
||||||
info.email = p.getValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ArrayList<>(r.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean addSuggestion(Map<Account.Id, AccountInfo> map,
|
private boolean addSuggestion(Set<Account.Id> map,
|
||||||
Account.Id account, VisibilityControl visibilityControl)
|
Account.Id account, VisibilityControl visibilityControl)
|
||||||
throws OrmException {
|
throws OrmException {
|
||||||
if (!map.containsKey(account)
|
if (!map.contains(account)
|
||||||
// Can the suggestion see the change?
|
// Can the suggestion see the change?
|
||||||
&& visibilityControl.isVisibleTo(account)
|
&& visibilityControl.isVisibleTo(account)
|
||||||
// Can the current user see the account?
|
// Can the current user see the account?
|
||||||
&& accountControl.canSee(account)) {
|
&& accountControl.canSee(account)) {
|
||||||
map.put(account, accountLoader.get(account));
|
map.add(account);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -282,7 +262,8 @@ public class ReviewersUtil {
|
|||||||
int size;
|
int size;
|
||||||
}
|
}
|
||||||
|
|
||||||
private GroupAsReviewer suggestGroupAsReviewer(SuggestReviewers suggestReviewers,
|
private GroupAsReviewer suggestGroupAsReviewer(
|
||||||
|
SuggestReviewers suggestReviewers,
|
||||||
Project project, GroupReference group,
|
Project project, GroupReference group,
|
||||||
VisibilityControl visibilityControl) throws OrmException, IOException {
|
VisibilityControl visibilityControl) throws OrmException, IOException {
|
||||||
GroupAsReviewer result = new GroupAsReviewer();
|
GroupAsReviewer result = new GroupAsReviewer();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 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.change;
|
||||||
|
|
||||||
|
import com.google.gerrit.extensions.annotations.ExtensionPoint;
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
import com.google.gerrit.reviewdb.client.Change;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener to provide reviewer suggestions.
|
||||||
|
* <p>
|
||||||
|
* Invoked by Gerrit a user who is searching for a reviewer to add to a change.
|
||||||
|
*/
|
||||||
|
@ExtensionPoint
|
||||||
|
public interface ReviewerSuggestion {
|
||||||
|
/**
|
||||||
|
* Reviewer suggestion.
|
||||||
|
*
|
||||||
|
* @param changeId The changeId that the suggestion is for.
|
||||||
|
* @param query The query as typed by the user. Can be an empty string.
|
||||||
|
* @param candidates A set of candidates for the ranking.
|
||||||
|
* @return Set of suggested reviewers as a tuple of account id and score.
|
||||||
|
* The account ids listed here don't have to be a part of candidates.
|
||||||
|
*/
|
||||||
|
Set<SuggestedReviewer> suggestReviewers(
|
||||||
|
Change.Id changeId, String query, Set<Account.Id> candidates);
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ public class SuggestChangeReviewers extends SuggestReviewers
|
|||||||
@Override
|
@Override
|
||||||
public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
|
public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
|
||||||
throws BadRequestException, OrmException, IOException {
|
throws BadRequestException, OrmException, IOException {
|
||||||
return reviewersUtil.suggestReviewers(this,
|
return reviewersUtil.suggestReviewers(rsrc.getNotes(), this,
|
||||||
rsrc.getControl().getProjectControl(), getVisibility(rsrc), excludeGroups);
|
rsrc.getControl().getProjectControl(), getVisibility(rsrc), excludeGroups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ public class SuggestReviewers {
|
|||||||
protected final ReviewersUtil reviewersUtil;
|
protected final ReviewersUtil reviewersUtil;
|
||||||
|
|
||||||
private final boolean suggestAccounts;
|
private final boolean suggestAccounts;
|
||||||
private final int suggestFrom;
|
|
||||||
private final int maxAllowed;
|
private final int maxAllowed;
|
||||||
private final int maxAllowedWithoutConfirmation;
|
private final int maxAllowedWithoutConfirmation;
|
||||||
protected int limit;
|
protected int limit;
|
||||||
@@ -62,10 +61,6 @@ public class SuggestReviewers {
|
|||||||
return suggestAccounts;
|
return suggestAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getSuggestFrom() {
|
|
||||||
return suggestFrom;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getLimit() {
|
public int getLimit() {
|
||||||
return limit;
|
return limit;
|
||||||
}
|
}
|
||||||
@@ -98,7 +93,6 @@ public class SuggestReviewers {
|
|||||||
this.suggestAccounts = (av != AccountVisibility.NONE);
|
this.suggestAccounts = (av != AccountVisibility.NONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
|
|
||||||
this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed",
|
this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed",
|
||||||
PostReviewers.DEFAULT_MAX_REVIEWERS);
|
PostReviewers.DEFAULT_MAX_REVIEWERS);
|
||||||
this.maxAllowedWithoutConfirmation = cfg.getInt(
|
this.maxAllowedWithoutConfirmation = cfg.getInt(
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 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.change;
|
||||||
|
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
|
||||||
|
public class SuggestedReviewer {
|
||||||
|
|
||||||
|
public Account.Id account;
|
||||||
|
public double score;
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ import com.google.gerrit.server.change.AccountPatchReviewStore;
|
|||||||
import com.google.gerrit.server.change.ChangeJson;
|
import com.google.gerrit.server.change.ChangeJson;
|
||||||
import com.google.gerrit.server.change.ChangeKindCacheImpl;
|
import com.google.gerrit.server.change.ChangeKindCacheImpl;
|
||||||
import com.google.gerrit.server.change.MergeabilityCacheImpl;
|
import com.google.gerrit.server.change.MergeabilityCacheImpl;
|
||||||
|
import com.google.gerrit.server.change.ReviewerSuggestion;
|
||||||
import com.google.gerrit.server.events.EventFactory;
|
import com.google.gerrit.server.events.EventFactory;
|
||||||
import com.google.gerrit.server.events.EventsMetrics;
|
import com.google.gerrit.server.events.EventsMetrics;
|
||||||
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
|
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
|
||||||
@@ -352,6 +353,7 @@ public class GerritGlobalModule extends FactoryModule {
|
|||||||
DynamicMap.mapOf(binder(), DownloadScheme.class);
|
DynamicMap.mapOf(binder(), DownloadScheme.class);
|
||||||
DynamicMap.mapOf(binder(), DownloadCommand.class);
|
DynamicMap.mapOf(binder(), DownloadCommand.class);
|
||||||
DynamicMap.mapOf(binder(), CloneCommand.class);
|
DynamicMap.mapOf(binder(), CloneCommand.class);
|
||||||
|
DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
|
||||||
DynamicSet.setOf(binder(), ExternalIncludedIn.class);
|
DynamicSet.setOf(binder(), ExternalIncludedIn.class);
|
||||||
DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
|
DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
|
||||||
DynamicSet.setOf(binder(), PatchSetWebLink.class);
|
DynamicSet.setOf(binder(), PatchSetWebLink.class);
|
||||||
|
|||||||
Reference in New Issue
Block a user