gitiles: add Refs API.

With docs, including best practice recommendation, and tests.

BUG=646067

Review-Url: https://codereview.chromium.org/2981263002
diff --git a/common/api/gitiles/gitiles.go b/common/api/gitiles/gitiles.go
index 9d9ec0b..ac6dfd9 100644
--- a/common/api/gitiles/gitiles.go
+++ b/common/api/gitiles/gitiles.go
@@ -160,8 +160,72 @@
 	}
 }
 
+// Refs returns a map resolving each ref in a repo to git revision.
+//
+// refsPath limits which refs to resolve to only those matching {refsPath}/*.
+// refsPath should start with "refs" and should not include glob '*'.
+// Typically, "refs/heads" should be used.
+//
+// To fetch **all** refs in a repo, specify just "refs" but beware of two
+// caveats:
+//  * refs returned include a ref for each patchset for each Gerrit change
+//    associated with the repo
+//  * returned map will contain special "HEAD" ref whose value in resulting map
+//    will be name of the actual ref to which "HEAD" points, which is typically
+//    "refs/heads/master".
+//
+// Thus, if you are looking for all tags and all branches of repo, it's
+// recommended to issue two Refs calls limited to "refs/tags" and "refs/heads"
+// instead of one call for "refs".
+//
+// Since Gerrit allows per-ref ACLs, it is possible that some refs matching
+// refPrefix would not be present in results because current user isn't granted
+// read permission on them.
+func (c *Client) Refs(ctx context.Context, repoURL, refsPath string) (map[string]string, error) {
+	repoURL, err := NormalizeRepoURL(repoURL)
+	if err != nil {
+		return nil, err
+	}
+	if refsPath != "refs" && !strings.HasPrefix(refsPath, "refs/") {
+		return nil, fmt.Errorf("refsPath must start with \"refs\": %q", refsPath)
+	}
+	refsPath = strings.TrimRight(refsPath, "/")
+
+	subPath := fmt.Sprintf("+%s?format=json", url.PathEscape(refsPath))
+	resp := refsResponse{}
+	if err := c.get(ctx, repoURL, subPath, &resp); err != nil {
+		return nil, err
+	}
+	r := make(map[string]string, len(resp))
+	for ref, v := range resp {
+		switch {
+		case v.Value == "":
+			// Weird case of what looks like hash with a target in at least Chromium
+			// repo.
+			continue
+		case ref == "HEAD":
+			r["HEAD"] = v.Target
+		case refsPath != "refs":
+			// Gitiles omits refsPath from each ref if refsPath != "refs". Undo this
+			// inconsistency.
+			r[refsPath+"/"+ref] = v.Value
+		default:
+			r[ref] = v.Value
+		}
+	}
+	return r, nil
+}
+
 ////////////////////////////////////////////////////////////////////////////////
 
+type refsResponseRefInfo struct {
+	Value  string `json:"value"`
+	Target string `json:"target"`
+}
+
+// refsResponse is the JSON response from querying gitiles for a Refs request.
+type refsResponse map[string]refsResponseRefInfo
+
 // logResponse is the JSON response from querying gitiles for a log request.
 type logResponse struct {
 	Log  []Commit `json:"log"`
diff --git a/common/api/gitiles/gitiles_test.go b/common/api/gitiles/gitiles_test.go
index b5e2290..83bfb7b 100644
--- a/common/api/gitiles/gitiles_test.go
+++ b/common/api/gitiles/gitiles_test.go
@@ -134,6 +134,66 @@
 	})
 }
 
+func TestRefs(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	Convey("Refs Bad RefsPath", t, func() {
+
+		c := Client{nil, "https://a.googlesource.com/a/repo"}
+		_, err := c.Refs(context.Background(), "https://c.googlesource.com/repo", "bad")
+		So(err, ShouldNotBeNil)
+	})
+
+	Convey("Refs All", t, func() {
+		srv, c := newMockClient(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(200)
+			w.Header().Set("Content-Type", "application/json")
+			fmt.Fprintln(w, `)]}'
+				{
+					"refs/heads/master": { "value": "deadbeef" },
+					"refs/heads/infra/config": { "value": "0000beef" },
+					"refs/changes/01/123001/1": { "value": "123dead001beef1" },
+					"refs/other/ref": { "value": "ba6" },
+					"123deadbeef123": { "target": "f00" },
+					"HEAD": { "value": "deadbeef",
+										"target": "refs/heads/master" }
+				}
+			`)
+		})
+		defer srv.Close()
+		refs, err := c.Refs(ctx, "https://c.googlesource.com/repo", "refs")
+		So(err, ShouldBeNil)
+		So(refs, ShouldResemble, map[string]string{
+			"HEAD":                     "refs/heads/master",
+			"refs/heads/master":        "deadbeef",
+			"refs/heads/infra/config":  "0000beef",
+			"refs/other/ref":           "ba6",
+			"refs/changes/01/123001/1": "123dead001beef1",
+			// Skipping "123dead001beef1" which has no value.
+		})
+	})
+	Convey("Refs heads", t, func() {
+		srv, c := newMockClient(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(200)
+			w.Header().Set("Content-Type", "application/json")
+			fmt.Fprintln(w, `)]}'
+				{
+					"master": { "value": "deadbeef" },
+					"infra/config": { "value": "0000beef" }
+				}
+			`)
+		})
+		defer srv.Close()
+		refs, err := c.Refs(ctx, "https://c.googlesource.com/repo", "refs/heads")
+		So(err, ShouldBeNil)
+		So(refs, ShouldResemble, map[string]string{
+			"refs/heads/master":       "deadbeef",
+			"refs/heads/infra/config": "0000beef",
+		})
+	})
+}
+
 ////////////////////////////////////////////////////////////////////////////////
 
 var (