Added a ls-members command to the ssh daemon.

Change-Id: Ie6272d48dfab90290ba6164acb01e1507f8d98b5
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 780231c..5637e80 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -60,6 +60,9 @@
 link:cmd-ls-groups.html[gerrit ls-groups]::
 	List groups visible to the caller.
 
+link:cmd-ls-members.html[gerrit ls-members]::
+	List the membership of a group visible to the caller.
+
 link:cmd-ls-projects.html[gerrit ls-projects]::
 	List projects visible to the caller.
 
diff --git a/Documentation/cmd-ls-members.txt b/Documentation/cmd-ls-members.txt
new file mode 100644
index 0000000..9814ff2
--- /dev/null
+++ b/Documentation/cmd-ls-members.txt
@@ -0,0 +1,64 @@
+gerrit ls-members
+================
+
+NAME
+----
+gerrit ls-members - Show members of a given group
+
+SYNOPSIS
+--------
+[verse]
+'ssh' -p <port> <host> 'gerrit ls-members GROUPNAME'
+  [--recursive]
+
+DESCRIPTION
+-----------
+Displays the members of the given group, one per line, so long as the given
+group is visible to the user. The users' id, username, full name and email are
+shown tab-separated.
+
+ACCESS
+------
+Any user who has configured an SSH key.
+
+SCRIPTING
+---------
+This command is intended to be used in scripts. Output is either an error
+message or a heading followed by zero or more lines, one for each member of the
+group. If any field is not set, or if the field is the user's full name and the
+name is empty, "n/a" is emitted as the field value.
+
+All non-printable characters (ASCII value 31 or less) are escaped
+according to the conventions used in languages like C, Python, and Perl,
+employing standard sequences like `\n` and `\t`, and `\xNN` for all
+others. In shell scripts, the `printf` command can be used to unescape
+the output.
+
+OPTIONS
+-------
+--recursive::
+	If a member of the group is itself a group, the sub-group's
+	members are included in the list. Otherwise members of any sub-group
+	are not shown and no indication is given that a sub-group is present
+
+EXAMPLES
+--------
+
+List members of the Administrators group:
+=====
+	$ ssh -p 29418 review.example.com gerrit ls-members Administrators
+	id      username  full name    email
+	100000  jim     Jim Bob somebody@example.com
+	100001  johnny  John Smith      n/a
+	100002  mrnoname        n/a     someoneelse@example.com
+=====
+
+List members of a non-existent group:
+=====
+	$ ssh -p 29418 review.example.com gerrit ls-members BadlySpelledGroup
+	Group not found or not visible
+=====
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index f17a741..54b10fd 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -31,7 +31,8 @@
   {
     "_account_id": 1000096,
     "name": "John Doe",
-    "email": "john.doe@example.com"
+    "email": "john.doe@example.com",
+    "username": "john"
   }
 ----
 
@@ -410,6 +411,8 @@
 |`email`       |optional|
 The email address the user prefers to be contacted through. +
 Only set if detailed account information is requested.
+|`username`    |optional|The username of the user. +
+Only set if detailed account information is requested.
 |===========================
 
 [[capability-info]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index e800d56..4785350 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -293,12 +293,14 @@
       {
         "_account_id": 1000097,
         "name": "Jane Roe",
-        "email": "jane.roe@example.com"
+        "email": "jane.roe@example.com",
+        "username": "jane"
       },
       {
         "_account_id": 1000096,
         "name": "John Doe",
         "email": "john.doe@example.com"
+        "username": "john"
       }
     ],
     "includes": []
@@ -620,12 +622,14 @@
     {
       "_account_id": 1000097,
       "name": "Jane Roe",
-      "email": "jane.roe@example.com"
+      "email": "jane.roe@example.com",
+      "username": "jane"
     },
     {
       "_account_id": 1000096,
       "name": "John Doe",
-      "email": "john.doe@example.com"
+      "email": "john.doe@example.com",
+      "username": "john"
     }
   ]
 ----
@@ -657,17 +661,20 @@
     {
       "_account_id": 1000097,
       "name": "Jane Roe",
-      "email": "jane.roe@example.com"
+      "email": "jane.roe@example.com",
+      "username": "jane"
     },
     {
       "_account_id": 1000096,
       "name": "John Doe",
-      "email": "john.doe@example.com"
+      "email": "john.doe@example.com",
+      "username": "john"
     },
     {
       "_account_id": 1000098,
       "name": "Richard Roe",
-      "email": "richard.roe@example.com"
+      "email": "richard.roe@example.com",
+      "username": "rroe"
     }
   ]
 ----
@@ -698,7 +705,8 @@
   {
     "_account_id": 1000096,
     "name": "John Doe",
-    "email": "john.doe@example.com"
+    "email": "john.doe@example.com",
+    "username": "john"
   }
 ----
 
@@ -728,7 +736,8 @@
   {
     "_account_id": 1000037,
     "name": "John Doe",
-    "email": "john.doe@example.com"
+    "email": "john.doe@example.com",
+    "username": "john"
   }
 ----
 
@@ -782,12 +791,14 @@
     {
       "_account_id": 1000057,
       "name": "Jane Roe",
-      "email": "jane.roe@example.com"
+      "email": "jane.roe@example.com",
+      "username": "jane"
     },
     {
       "_account_id": 1000037,
       "name": "John Doe",
-      "email": "john.doe@example.com"
+      "email": "john.doe@example.com",
+      "username": "john"
     }
   ]
 ----
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfo.java
index a296716..2f95368 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfo.java
@@ -112,12 +112,14 @@
   public Integer _account_id;
   public String name;
   public String email;
+  public String username;
 
   private void fill(Account account, boolean detailed) {
     name = account.getFullName();
     if (detailed) {
       _account_id = account.getId().get();
       email = account.getPreferredEmail();
+      username = account.getUserName();
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
index d32c632..28f908c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
@@ -49,7 +49,7 @@
   private boolean recursive;
 
   @Inject
-  ListMembers(GroupCache groupCache,
+  protected ListMembers(GroupCache groupCache,
       GroupDetailFactory.Factory groupDetailFactory,
       AccountInfo.Loader.Factory accountLoaderFactory) {
     this.groupCache = groupCache;
@@ -63,8 +63,19 @@
     if (resource.toAccountGroup() == null) {
       throw new MethodNotAllowedException();
     }
+
+    return apply(resource.getGroupUUID());
+  }
+
+  public List<AccountInfo> apply(AccountGroup group)
+      throws MethodNotAllowedException, OrmException {
+    return apply(group.getGroupUUID());
+  }
+
+  public List<AccountInfo> apply(AccountGroup.UUID groupId)
+      throws MethodNotAllowedException, OrmException {
     final Map<Account.Id, AccountInfo> members =
-        getMembers(resource.getGroupUUID(), new HashSet<AccountGroup.UUID>());
+        getMembers(groupId, new HashSet<AccountGroup.UUID>());
     final List<AccountInfo> memberInfos = Lists.newArrayList(members.values());
     Collections.sort(memberInfos, new Comparator<AccountInfo>() {
       @Override
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 35a4e14..521103b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -39,6 +39,7 @@
     command(gerrit, BanCommitCommand.class);
     command(gerrit, FlushCaches.class);
     command(gerrit, ListProjectsCommand.class);
+    command(gerrit, ListMembersCommand.class);
     command(gerrit, ListGroupsCommand.class);
     command(gerrit, LsUserRefs.class);
     command(gerrit, Query.class);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
new file mode 100644
index 0000000..c5bda3c
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -0,0 +1,116 @@
+// 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.sshd.commands;
+
+import com.google.common.base.Objects;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountInfo;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupDetailFactory.Factory;
+import com.google.gerrit.server.group.ListMembers;
+import com.google.gerrit.server.ioutil.ColumnFormatter;
+import com.google.gerrit.sshd.BaseCommand;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gwtorm.server.OrmException;
+
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Argument;
+
+import java.io.PrintWriter;
+import java.util.List;
+
+import javax.inject.Inject;
+
+/**
+ * Implements a command that allows the user to see the members of a group.
+ */
+@CommandMetaData(name = "ls-members", descr = "Lists the members of a given group")
+public class ListMembersCommand extends BaseCommand {
+  @Inject
+  ListMembersCommandImpl impl;
+
+  @Override
+  public void start(Environment env) {
+    startThread(new CommandRunnable() {
+      @Override
+      public void run() throws Exception {
+        parseCommandLine(impl);
+        final PrintWriter stdout = toPrintWriter(out);
+        try {
+          impl.display(stdout);
+        } finally {
+          stdout.flush();
+        }
+      }
+    });
+  }
+
+  private static class ListMembersCommandImpl extends ListMembers {
+    @Argument(required = true, usage = "the name of the group", metaVar = "GROUPNAME")
+    private String name;
+
+    private final GroupCache groupCache;
+
+    @Inject
+    protected ListMembersCommandImpl(GroupCache groupCache,
+        Factory groupDetailFactory,
+        AccountInfo.Loader.Factory accountLoaderFactory,
+        AccountCache accountCache) {
+      super(groupCache, groupDetailFactory, accountLoaderFactory);
+      this.groupCache = groupCache;
+    }
+
+    void display(PrintWriter writer) throws UnloggedFailure, OrmException {
+      AccountGroup group = groupCache.get(new AccountGroup.NameKey(name));
+      String errorText = "Group not found or not visible\n";
+
+      if (group == null) {
+        writer.write(errorText);
+        writer.flush();
+        return;
+      }
+
+      try {
+        List<AccountInfo> members = apply(group.getGroupUUID());
+        ColumnFormatter formatter = new ColumnFormatter(writer, '\t');
+        formatter.addColumn("id");
+        formatter.addColumn("username");
+        formatter.addColumn("full name");
+        formatter.addColumn("email");
+        formatter.nextLine();
+        for (AccountInfo member : members) {
+          if (member == null) {
+            continue;
+          }
+
+          formatter.addColumn(member._id.toString());
+          formatter.addColumn(Objects.firstNonNull(member.username, "n/a"));
+          formatter.addColumn(Objects.firstNonNull(
+              Strings.emptyToNull(member.name), "n/a"));
+          formatter.addColumn(Objects.firstNonNull(member.email, "n/a"));
+          formatter.nextLine();
+        }
+
+        formatter.finish();
+      } catch (MethodNotAllowedException e) {
+        writer.write(errorText);
+        writer.flush();
+      }
+    }
+  }
+}