Allow group descriptions to supply email and URL

Some backends have external management interfaces that are not
embedded into Gerrit Code Review. Allow those backends to supply
a URL to the web management interface for a group, so a user can
manage their membership, view current members, or do whatever other
features the group system might support.

Some backends also have an email address associated with every
group. Sending email to that address will distribute the message to
the group's members. Permit backends to supply an optional email
address, and use this in the project level notification system if
a group is selected as the target for a message.

Change-Id: Ifaebc01571c2db84872b2c08ff99c05389372f61
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
index 67eb834..b74a9f3 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.common;
 
 import com.google.gerrit.common.data.ChangeInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -101,6 +102,10 @@
       return status(status) + " " + op("project", proj.get());
   }
 
+  public static String toGroup(AccountGroup.UUID uuid) {
+    return ADMIN_GROUPS + "uuid-" + uuid;
+  }
+
   private static String status(Status status) {
     switch (status) {
       case ABANDONED:
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
index 828bf24..a6304c3 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
@@ -16,6 +16,8 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
+import javax.annotation.Nullable;
+
 /**
  * Group methods exposed by the GroupBackend.
  */
@@ -32,6 +34,22 @@
 
     /** @return whether the group is visible to all accounts. */
     boolean isVisibleToAll();
+
+    /**
+     * @return optional email address to send to the group's members. If
+     *         provided, Gerrit will use this email address to send
+     *         change notifications to the group.
+     */
+    @Nullable
+    String getEmailAddress();
+
+    /**
+     * @return optional URL to information about the group. Typically a URL to a
+     *         web page that permits users to apply to join the group, or manage
+     *         their membership.
+     */
+    @Nullable
+    String getUrl();
   }
 
   /**
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
index e0bc7d8..46ad016 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
 import javax.annotation.Nullable;
@@ -52,6 +53,18 @@
       public AccountGroup getAccountGroup() {
         return group;
       }
+
+      @Override
+      @Nullable
+      public String getEmailAddress() {
+        return null;
+      }
+
+      @Override
+      @Nullable
+      public String getUrl() {
+        return "#" + PageLinks.toGroup(getGroupUUID());
+      }
     };
   }
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
index c4d92d4..1acdd9a 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
@@ -21,6 +21,7 @@
   protected AccountGroup.UUID uuid;
   protected String name;
   protected String description;
+  protected String url;
 
   protected GroupInfo() {
   }
@@ -44,6 +45,7 @@
   public GroupInfo(GroupDescription.Basic a) {
     uuid = a.getGroupUUID();
     name = a.getName();
+    url = a.getUrl();
 
     if (a instanceof GroupDescription.Internal) {
       AccountGroup group = ((GroupDescription.Internal) a).getAccountGroup();
@@ -65,4 +67,8 @@
   public String getDescription() {
     return description;
   }
+
+  public String getUrl() {
+    return url;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index 801ee43..75ad56c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -145,12 +145,12 @@
     return "/admin/groups/" + id.toString() + "," + panel;
   }
 
-  public static String toGroup(final AccountGroup.UUID uuid) {
-    return "/admin/groups/uuid-" + uuid.toString();
+  public static String toGroup(AccountGroup.UUID uuid) {
+    return PageLinks.toGroup(uuid);
   }
 
   public static String toGroup(AccountGroup.UUID uuid, String panel) {
-    return "/admin/groups/uuid-" + uuid.toString() + "," + panel;
+    return toGroup(uuid) + "," + panel;
   }
 
   public static String toProject(Project.NameKey n) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
index e3bda5d..f83e9ec 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
@@ -24,7 +24,7 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    groups = new GroupTable(true /* hyperlink to admin */);
+    groups = new GroupTable();
     add(groups);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 7a0369f..e4086d2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
@@ -369,6 +370,14 @@
             new Hyperlink(info.getName(), Dispatcher.toGroup(uuid)));
         fmt.getElement(row, 2).setTitle(null);
         table.setText(row, 3, info.getDescription());
+      } else if (info.getUrl() != null) {
+        Anchor a = new Anchor();
+        a.setText(info.getName());
+        a.setHref(info.getUrl());
+        a.setTitle("UUID " + uuid.get());
+        table.setWidget(row, 2, a);
+        fmt.getElement(row, 2).setTitle(null);
+        table.clearCell(row, 3);
       } else {
         table.setText(row, 2, info.getName());
         fmt.getElement(row, 2).setTitle("UUID " + uuid.get());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
index 0a8cad8..07f25f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
@@ -82,7 +82,7 @@
     setPageTitle(Util.C.groupListTitle());
     initPageHeader();
 
-    groups = new GroupTable(true /* hyperlink to admin */, PageLinks.ADMIN_GROUPS);
+    groups = new GroupTable(PageLinks.ADMIN_GROUPS);
     add(groups);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index 2ec0edc..83959c6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.admin;
 
+import static com.google.gerrit.client.admin.Util.C;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.groups.GroupInfo;
@@ -21,9 +22,12 @@
 import com.google.gerrit.client.groups.GroupMap;
 import com.google.gerrit.client.ui.HighlightingInlineHyperlink;
 import com.google.gerrit.client.ui.NavigationTable;
+import com.google.gerrit.client.ui.Util;
+import com.google.gerrit.common.PageLinks;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.user.client.History;
+import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.HTMLTable.Cell;
 import com.google.gwt.user.client.ui.Image;
@@ -36,20 +40,17 @@
 public class GroupTable extends NavigationTable<GroupInfo> {
   private static final int NUM_COLS = 3;
 
-  private final boolean enableLink;
-
-  public GroupTable(final boolean enableLink) {
-    this(enableLink, null);
+  public GroupTable() {
+    this(null);
   }
 
-  public GroupTable(final boolean enableLink, final String pointerId) {
-    super(Util.C.groupItemHelp());
-    this.enableLink = enableLink;
+  public GroupTable(final String pointerId) {
+    super(C.groupItemHelp());
     setSavePointerId(pointerId);
 
-    table.setText(0, 1, Util.C.columnGroupName());
-    table.setText(0, 2, Util.C.columnGroupDescription());
-    table.setText(0, 3, Util.C.columnGroupVisibleToAll());
+    table.setText(0, 1, C.columnGroupName());
+    table.setText(0, 2, C.columnGroupDescription());
+    table.setText(0, 3, C.columnGroupVisibleToAll());
     table.addClickHandler(new ClickHandler() {
       @Override
       public void onClick(ClickEvent event) {
@@ -104,11 +105,18 @@
   }
 
   void populate(final int row, final GroupInfo k, final String toHighlight) {
-    if (enableLink) {
-      table.setWidget(row, 1, new HighlightingInlineHyperlink(k.name(),
-          Dispatcher.toGroup(k.getGroupId()), toHighlight));
+    if (k.url() != null) {
+      if (k.url().startsWith("#" + PageLinks.ADMIN_GROUPS)) {
+        table.setWidget(row, 1, new HighlightingInlineHyperlink(k.name(),
+            Dispatcher.toGroup(k.getGroupId()), toHighlight));
+      } else {
+        Anchor link = new Anchor();
+        link.setHTML(Util.highlight(k.name(), toHighlight));
+        link.setHref(k.url());
+        table.setWidget(row, 1, link);
+      }
     } else {
-      table.setText(row, 1, k.name());
+      table.setHTML(row, 1, Util.highlight(k.name(), toHighlight));
     }
     table.setText(row, 2, k.description());
     if (k.isVisibleToAll()) {
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 503035e..33849a8 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
@@ -31,6 +31,7 @@
   public final native String name() /*-{ return this.name; }-*/;
   public final native boolean isVisibleToAll() /*-{ return this['visible_to_all'] ? true : false; }-*/;
   public final native String description() /*-{ return this.description; }-*/;
+  public final native String url() /*-{ return this.url; }-*/;
 
   private final native int group_id() /*-{ return this.group_id; }-*/;
   private final native String owner_uuid() /*-{ return this.owner_uuid; }-*/;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 7b1353b..3b1b330 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -47,6 +47,7 @@
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
+import javax.annotation.Nullable;
 import javax.naming.InvalidNameException;
 import javax.naming.NamingException;
 import javax.naming.directory.DirContext;
@@ -152,6 +153,18 @@
       public boolean isVisibleToAll() {
         return false;
       }
+
+      @Override
+      @Nullable
+      public String getEmailAddress() {
+        return null;
+      }
+
+      @Override
+      @Nullable
+      public String getUrl() {
+        return null;
+      }
     };
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfo.java
index bcefe81..8bb8baa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfo.java
@@ -21,8 +21,9 @@
 
 public class GroupInfo {
   final String kind = "gerritcodereview#group";
-  public String id;
-  public String name;
+  String id;
+  String name;
+  String url;
   Boolean visibleToAll;
 
   // These fields are only supplied for internal groups.
@@ -33,6 +34,7 @@
   public GroupInfo(GroupDescription.Basic group) {
     id = Url.encode(group.getGroupUUID().get());
     name = Strings.emptyToNull(group.getName());
+    url = Strings.emptyToNull(group.getUrl());
     visibleToAll = group.isVisibleToAll() ? true : null;
 
     if (group instanceof GroupDescription.Internal) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
index 55c9f91..bc9ef3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.EmailException;
@@ -418,22 +420,7 @@
   private void add(Watchers matching, NotifyConfig nc, Project.NameKey project)
       throws OrmException, QueryParseException {
     for (GroupReference ref : nc.getGroups()) {
-      AccountGroup group =
-          GroupDescriptions.toAccountGroup(args.groupBackend.get(ref.getUUID()));
-      if (group == null) {
-        log.warn(String.format(
-            "Project %s has invalid group %s in notify section %s",
-            project.get(), ref.getName(), nc.getName()));
-        continue;
-      }
-
-      if (group.getType() != AccountGroup.Type.INTERNAL) {
-        log.warn(String.format(
-            "Project %s cannot use group %s of type %s in notify section %s",
-            project.get(), ref.getName(), group.getType(), nc.getName()));
-        continue;
-      }
-
+      GroupDescription.Basic group = args.groupBackend.get(ref.getUUID());
       ChangeQueryBuilder qb = args.queryBuilder.create(new SingleGroupUser(
           args.capabilityControlFactory,
           ref.getUUID()));
@@ -443,9 +430,29 @@
         p = Predicate.and(qb.parse(nc.getFilter()), p);
         p = args.queryRewriter.get().rewrite(p);
       }
-      if (p.match(changeData)) {
-        recursivelyAddAllAccounts(matching.list(nc.getHeader()), group);
+      if (!p.match(changeData)) {
+        continue;
       }
+
+      if (Strings.isNullOrEmpty(group.getEmailAddress())) {
+        matching.list(nc.getHeader()).emails.add(new Address(group.getEmailAddress()));
+        continue;
+      }
+
+      AccountGroup ig = GroupDescriptions.toAccountGroup(group);
+      if (ig == null) {
+        log.warn(String.format(
+            "Project %s has invalid group %s in notify section %s",
+            project.get(), ref.getName(), nc.getName()));
+        continue;
+      }
+      if (ig.getType() != AccountGroup.Type.INTERNAL) {
+        log.warn(String.format(
+            "Project %s cannot use group %s of type %s in notify section %s",
+            project.get(), ref.getName(), ig.getType(), nc.getName()));
+        continue;
+      }
+      recursivelyAddAllAccounts(matching.list(nc.getHeader()), ig);
     }
 
     if (!nc.getAddresses().isEmpty()) {