diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt index 65dd3a4041..bc5d0bd68a 100644 --- a/Documentation/rest-api-changes.txt +++ b/Documentation/rest-api-changes.txt @@ -1020,6 +1020,48 @@ As result a list of link:#reviewer-info[ReviewerInfo] entries is returned. ] ---- +[[suggest-reviewers]] +Suggest Reviewers +~~~~~~~~~~~~~~~~~ +[verse] +'GET /changes/link:#change-id[\{change-id\}]/suggest_reviewers?q=J&n=5' + +Suggest the reviewers for a given query `q` and result limit `n`. If result +limit is not passed, then the default 10 is used. + +As result a list of link:#suggested-reviewer-info[SuggestedReviewerInfo] entries is returned. + +.Request +---- + GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?q=J HTTP/1.0 +---- + +.Response +---- + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json;charset=UTF-8 + + )]}' + [ + { + "kind": "gerritcodereview#suggestedreviewer", + "account": { + "_account_id": 1000097, + "name": "Jane Roe", + "email": "jane.roe@example.com" + } + }, + { + "kind": "gerritcodereview#suggestedreviewer", + "group": { + "id": "4fd581c0657268f2bdcc26699fbf9ddb76e3a279", + "name": "Joiner" + } + } + ] +---- + [[get-reviewer]] Get Reviewer ~~~~~~~~~~~~ @@ -2540,6 +2582,18 @@ permitted to vote on that label. The time and date describing when the approval was made. |=========================== +[[group-base-info]] +GroupBaseInfo +~~~~~~~~~~~~~ +The `GroupBaseInfo` entity contains base information about the group. + +[options="header",width="50%",cols="1,6"] +|========================== +|Field Name |Description +|`id` |The id of the group. +|`name` |The name of the group. +|========================== + [[change-info]] ChangeInfo ~~~~~~~~~~ @@ -3118,6 +3172,17 @@ If `SKIP` the parent filters are not called, allowing the test to return results from the input rule. |=========================== +[[suggested-reviewer-info]] +SuggestedReviewerInfo +~~~~~~~~~~~~~~~~~~~~~ +The `SuggestedReviewerInfo` entity contains information about a reviewer +that can be added to a change (an account or a group). + +`SuggestedReviewerInfo` has either the `account` field that contains +the link:rest-api-accounts.html#account-info[AccountInfo] entity, or +the `group` field that contains the +link:rest-api-changes.html#group-base-info[GroupBaseInfo] entity. + [[submit-info]] SubmitInfo ~~~~~~~~~~ diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java new file mode 100644 index 0000000000..4811e5986b --- /dev/null +++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java @@ -0,0 +1,31 @@ +// Copyright (C) 2013 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.client.groups; + +import com.google.gerrit.reviewdb.client.AccountGroup; +import com.google.gwt.core.client.JavaScriptObject; +import com.google.gwt.http.client.URL; + +public class GroupBaseInfo extends JavaScriptObject { + public final AccountGroup.UUID getGroupUUID() { + return new AccountGroup.UUID(URL.decodeQueryString(id())); + } + + public final native String id() /*-{ return this.id; }-*/; + public final native String name() /*-{ return this.name; }-*/; + + protected GroupBaseInfo() { + } +} diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java index f52999020a..f1e4e871af 100644 --- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java +++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java @@ -20,17 +20,11 @@ import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.core.client.JsArray; import com.google.gwt.http.client.URL; -public class GroupInfo extends JavaScriptObject { +public class GroupInfo extends GroupBaseInfo { public final AccountGroup.Id getGroupId() { return new AccountGroup.Id(group_id()); } - public final AccountGroup.UUID getGroupUUID() { - return new AccountGroup.UUID(URL.decodeQueryString(id())); - } - - public final native String id() /*-{ return this.id; }-*/; - public final native String name() /*-{ return this.name; }-*/; public final native GroupOptionsInfo options() /*-{ return this.options; }-*/; public final native String description() /*-{ return this.description; }-*/; public final native String url() /*-{ return this.url; }-*/; diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java index 25bd80f088..d1fd73015a 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java @@ -59,6 +59,7 @@ public class Module extends RestApiModule { post(CHANGE_KIND, "index").to(Index.class); post(CHANGE_KIND, "reviewers").to(PostReviewers.class); + get(CHANGE_KIND, "suggest_reviewers").to(SuggestReviewers.class); child(CHANGE_KIND, "reviewers").to(Reviewers.class); get(REVIEWER_KIND).to(GetReviewer.class); delete(REVIEWER_KIND).to(DeleteReviewer.class); diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java new file mode 100644 index 0000000000..1a670cc83b --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java @@ -0,0 +1,296 @@ +// Copyright (C) 2013 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.common.base.Objects; +import com.google.common.base.Strings; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.gerrit.common.data.GroupReference; +import com.google.gerrit.common.errors.NoSuchGroupException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.RestReadView; +import com.google.gerrit.extensions.restapi.Url; +import com.google.gerrit.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.AccountExternalId; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.account.AccountControl; +import com.google.gerrit.server.account.AccountInfo; +import com.google.gerrit.server.account.AccountVisibility; +import com.google.gerrit.server.account.GroupBackend; +import com.google.gerrit.server.account.GroupMembers; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.group.GroupJson.GroupBaseInfo; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.gerrit.server.project.ProjectControl; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.Provider; + +import org.eclipse.jgit.lib.Config; +import org.kohsuke.args4j.Option; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +class SuggestReviewers implements RestReadView { + + private static final String MAX_SUFFIX = "\u9fa5"; + private static final int MAX = 10; + + private final AccountInfo.Loader.Factory accountLoaderFactory; + private final AccountControl.Factory accountControlFactory; + private final GroupMembers.Factory groupMembersFactory; + private final AccountCache accountCache; + private final Provider dbProvider; + private final Provider currentUser; + private final IdentifiedUser.GenericFactory identifiedUserFactory; + private final GroupBackend groupBackend; + private final boolean suggestAccounts; + private final int suggestFrom; + private final int maxAllowed; + private int limit; + private String query; + + @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", + usage = "maximum number of reviewers to list") + public void setLimit(int l) { + this.limit = l <= 0 ? MAX : Math.min(l, MAX); + } + + @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", + usage = "match reviewers query") + public void setQuery(String q) { + this.query = q; + } + + @Inject + SuggestReviewers(AccountVisibility av, + AccountInfo.Loader.Factory accountLoaderFactory, + AccountControl.Factory accountControlFactory, + AccountCache accountCache, + GroupMembers.Factory groupMembersFactory, + IdentifiedUser.GenericFactory identifiedUserFactory, + Provider currentUser, + Provider dbProvider, + @GerritServerConfig Config cfg, + GroupBackend groupBackend) { + this.accountLoaderFactory = accountLoaderFactory; + this.accountControlFactory = accountControlFactory; + this.accountCache = accountCache; + this.groupMembersFactory = groupMembersFactory; + this.dbProvider = dbProvider; + this.identifiedUserFactory = identifiedUserFactory; + this.currentUser = currentUser; + this.groupBackend = groupBackend; + + String suggest = cfg.getString("suggest", null, "accounts"); + if ("OFF".equalsIgnoreCase(suggest) + || "false".equalsIgnoreCase(suggest)) { + this.suggestAccounts = false; + } else { + this.suggestAccounts = (av != AccountVisibility.NONE); + } + + this.suggestFrom = cfg.getInt("suggest", null, "from", 0); + this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", + PostReviewers.DEFAULT_MAX_REVIEWERS); + } + + private interface VisibilityControl { + boolean isVisibleTo(Account account) throws OrmException; + } + + @Override + public List apply(ChangeResource rsrc) + throws BadRequestException, OrmException, IOException { + if (Strings.isNullOrEmpty(query)) { + throw new BadRequestException("missing query field"); + } + + if (!suggestAccounts || query.length() < suggestFrom) { + return Collections.emptyList(); + } + + VisibilityControl visibilityControl = getVisibility(rsrc); + List suggestedAccounts = suggestAccount(visibilityControl); + accountLoaderFactory.create(true).fill(suggestedAccounts); + + List reviewer = Lists.newArrayList(); + for (AccountInfo a : suggestedAccounts) { + reviewer.add(new SuggestedReviewerInfo(a)); + } + + Project p = rsrc.getControl().getProject(); + for (GroupReference g : suggestAccountGroup( + rsrc.getControl().getProjectControl())) { + if (suggestGroupAsReviewer(p, g, visibilityControl)) { + GroupBaseInfo info = new GroupBaseInfo(); + info.id = Url.encode(g.getUUID().get()); + info.name = g.getName(); + reviewer.add(new SuggestedReviewerInfo(info)); + } + } + + Collections.sort(reviewer); + if (reviewer.size() <= limit) { + return reviewer; + } else { + return reviewer.subList(0, limit); + } + } + + private VisibilityControl getVisibility(final ChangeResource rsrc) { + if (rsrc.getControl().getRefControl().isVisibleByRegisteredUsers()) { + return new VisibilityControl() { + @Override + public boolean isVisibleTo(Account account) throws OrmException { + return true; + } + }; + } else { + return new VisibilityControl() { + @Override + public boolean isVisibleTo(Account account) throws OrmException { + IdentifiedUser who = + identifiedUserFactory.create(dbProvider, account.getId()); + // we can't use changeControl directly as it won't suggest reviewers + // to drafts + return rsrc.getControl().forUser(who).isRefVisible(); + } + }; + } + } + + private List suggestAccountGroup(ProjectControl ctl) { + return Lists.newArrayList( + Iterables.limit(groupBackend.suggest(query, ctl), limit)); + } + + private List suggestAccount(VisibilityControl visibilityControl) + throws OrmException { + String a = query; + String b = a + MAX_SUFFIX; + + LinkedHashMap r = Maps.newLinkedHashMap(); + for (Account p : dbProvider.get().accounts() + .suggestByFullName(a, b, limit)) { + addSuggestion(r, p, new AccountInfo(p.getId()), visibilityControl); + } + + if (r.size() < limit) { + for (Account p : dbProvider.get().accounts() + .suggestByPreferredEmail(a, b, limit - r.size())) { + addSuggestion(r, p, new AccountInfo(p.getId()), visibilityControl); + } + } + + if (r.size() < limit) { + for (AccountExternalId e : dbProvider.get().accountExternalIds() + .suggestByEmailAddress(a, b, limit - r.size())) { + if (!r.containsKey(e.getAccountId())) { + Account p = accountCache.get(e.getAccountId()).getAccount(); + AccountInfo info = new AccountInfo(p.getId()); + addSuggestion(r, p, info, visibilityControl); + } + } + } + + return Lists.newArrayList(r.values()); + } + + private void addSuggestion(Map map, Account account, + AccountInfo info, VisibilityControl visibilityControl) + throws OrmException { + if (!map.containsKey(account.getId()) + && account.isActive() + // Can the suggestion see the change? + && visibilityControl.isVisibleTo(account) + // Can the account see the current user? + && accountControlFactory.get().canSee(account)) { + map.put(account.getId(), info); + } + } + + private boolean suggestGroupAsReviewer(Project project, + GroupReference group, VisibilityControl visibilityControl) + throws OrmException, IOException { + if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) { + return false; + } + + try { + Set members = groupMembersFactory + .create(currentUser.get()) + .listAccounts(group.getUUID(), project.getNameKey()); + + if (members.isEmpty()) { + return false; + } + + if (maxAllowed > 0 && members.size() > maxAllowed) { + return false; + } + + // require that at least one member in the group can see the change + for (Account account : members) { + if (visibilityControl.isVisibleTo(account)) { + return true; + } + } + } catch (NoSuchGroupException e) { + return false; + } catch (NoSuchProjectException e) { + return false; + } + + return false; + } + + static class SuggestedReviewerInfo implements Comparable { + String kind = "gerritcodereview#suggestedreviewer"; + AccountInfo account; + GroupBaseInfo group; + + SuggestedReviewerInfo(AccountInfo a) { + this.account = a; + } + + SuggestedReviewerInfo(GroupBaseInfo g) { + this.group = g; + } + + @Override + public int compareTo(SuggestedReviewerInfo o) { + return getSortValue().compareTo(o.getSortValue()); + } + + private String getSortValue() { + return account != null + ? Objects.firstNonNull(account.email, + Strings.nullToEmpty(account.name)) + : Strings.nullToEmpty(group.name); + } + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java index a74e6ef853..8c9056de45 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java @@ -126,10 +126,8 @@ public class GroupJson { } } - public static class GroupInfo { + public static class GroupInfo extends GroupBaseInfo { final String kind = "gerritcodereview#group"; - public String id; - public String name; public String url; public GroupOptionsInfo options; @@ -143,4 +141,9 @@ public class GroupJson { public List members; public List includes; } + + public static class GroupBaseInfo { + public String id; + public String name; + } }