[tools][whereiscl] first change for showing whether a CL passed GI

This CL only retrieves the the CL's commit revision and the GI's current
revision. A follow-up CL will do the actual comparison of whether the
former is before or after the latter.

Bug: DX-1516  #comment
Change-Id: Id26d7eefb9455e0d7e334e3cc6d8fa489b1b0e3e
diff --git a/tools/whereiscl/OWNERS b/tools/whereiscl/OWNERS
new file mode 100644
index 0000000..210bc29
--- /dev/null
+++ b/tools/whereiscl/OWNERS
@@ -0,0 +1,2 @@
+jinwoo@google.com
+pylaligand@google.com
diff --git a/tools/whereiscl/changeinfo.go b/tools/whereiscl/changeinfo.go
new file mode 100644
index 0000000..9a93099
--- /dev/null
+++ b/tools/whereiscl/changeinfo.go
@@ -0,0 +1,79 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+)
+
+type clStatus string
+
+const (
+	clStatusNew       clStatus = "NEW"
+	clStatusMerged    clStatus = "MERGED"
+	clStatusAbandoned clStatus = "ABANDONED"
+)
+
+// changeInfo is a JSON struct for ChangeInfo responses from Gerrit.
+// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info.
+// Only fields of interest are listed here.
+type changeInfo struct {
+	Project         string   `json:"project"`
+	Status          clStatus `json:"status"`
+	CurrentRevision string   `json:"current_revision"`
+}
+
+type queryInfo struct{ apiEndpoint, cl string }
+
+func makeQueryURL(qi queryInfo) (*url.URL, error) {
+	u, err := url.Parse(qi.apiEndpoint)
+	if err != nil {
+		return nil, err
+	}
+	u.Path = "/changes/"
+	q := u.Query()
+	q.Set("q", qi.cl)
+	q.Add("o", "CURRENT_REVISION")
+	u.RawQuery = q.Encode()
+	return u, nil
+}
+
+func getChangeInfo(qi queryInfo) (*changeInfo, error) {
+	q, err := makeQueryURL(qi)
+	if err != nil {
+		return nil, err
+	}
+	resp, err := http.Get(q.String())
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	b, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	var cis []changeInfo
+	// Responses start with )]}' to prevent XSSI attacks. Discard them.
+	// See https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
+	if err := json.Unmarshal(b[4:], &cis); err != nil {
+		return nil, err
+	}
+
+	switch len(cis) {
+	case 0:
+		return nil, errors.New("CL not found")
+	case 1:
+		return &cis[0], nil
+	default:
+		return nil, fmt.Errorf("Got %d CLs while expecting only one", len(cis))
+	}
+}
diff --git a/tools/whereiscl/changeinfo_test.go b/tools/whereiscl/changeinfo_test.go
new file mode 100644
index 0000000..d2377a4
--- /dev/null
+++ b/tools/whereiscl/changeinfo_test.go
@@ -0,0 +1,82 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package main
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestGetChangeInfo(t *testing.T) {
+	cl := "987654321"
+	http.DefaultClient.Transport = &mockTransport{
+		cl: cl,
+		body: `)]}'
+[
+  {
+    "foo": 42,
+    "status": "MERGED",
+    "current_revision": "abcdefg"
+  }
+]`,
+	}
+	got, err := getChangeInfo(queryInfo{
+		apiEndpoint: "https://fuchsia-review.googlesource.com",
+		cl:          cl,
+	})
+	if err != nil {
+		t.Fatalf("getChangeInfo: %v", err)
+	}
+	want := &changeInfo{
+		Status:          clStatusMerged,
+		CurrentRevision: "abcdefg",
+	}
+	if d := cmp.Diff(want, got); d != "" {
+		t.Errorf("getChangeInfo: mismatch (-want +got):\n%s", d)
+	}
+}
+
+func TestGetChangeInfo_clNotFound(t *testing.T) {
+	cl := "987654321"
+	http.DefaultClient.Transport = &mockTransport{
+		cl: cl,
+		body: `)]}'
+[]`,
+	}
+	_, err := getChangeInfo(queryInfo{
+		apiEndpoint: "https://fuchsia-review.googlesource.com",
+		cl:          cl,
+	})
+	if err == nil {
+		t.Error("getChangeInfo: error expected; got nil")
+	}
+}
+
+func TestGetChangeInfo_tooManyCLs(t *testing.T) {
+	cl := "987654321"
+	http.DefaultClient.Transport = &mockTransport{
+		cl: cl,
+		body: `)]}'
+[
+  {
+    "status": "ACTIVE",
+    "current_revision": "abcdefg"
+  },
+  {
+    "status": "MERGED",
+    "current_revision": "hijklmn"
+  }
+]`,
+	}
+	_, err := getChangeInfo(queryInfo{
+		apiEndpoint: "https://fuchsia-review.googlesource.com",
+		cl:          cl,
+	})
+	if err == nil {
+		t.Error("getChangeInfo: error expected; got nil")
+	}
+}
diff --git a/tools/whereiscl/gistatus.go b/tools/whereiscl/gistatus.go
new file mode 100644
index 0000000..a58578b
--- /dev/null
+++ b/tools/whereiscl/gistatus.go
@@ -0,0 +1,102 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/base64"
+	"encoding/xml"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+)
+
+const fuchsiaURL = "https://fuchsia.googlesource.com"
+
+type giStatus string
+
+const (
+	giStatusUnknown giStatus = "UNKNOWN"
+	giStatusPassed           = "PASSED"
+	giStatusPending          = "PENDING"
+)
+
+func downloadGIManifest(name string) ([]byte, error) {
+	u, err := url.Parse(fuchsiaURL)
+	if err != nil {
+		return nil, err
+	}
+	u.Path = "/integration/+/refs/heads/master/" + name
+	q := u.Query()
+	q.Add("format", "TEXT")
+	u.RawQuery = q.Encode()
+
+	resp, err := http.Get(u.String())
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	b, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	return base64.StdEncoding.DecodeString(string(b))
+}
+
+// Structs for unmarshalling the jiri manifest XML data.
+type manifest struct {
+	XMLName  xml.Name  `xml:"manifest"`
+	Projects []project `xml:"projects>project"`
+}
+type project struct {
+	XMLName  xml.Name `xml:"project"`
+	Name     string   `xml:"name,attr"`
+	Revision string   `xml:"revision,attr"`
+}
+
+func getGIRevision(content []byte, project string) (string, error) {
+	m := manifest{}
+	if err := xml.Unmarshal(content, &m); err != nil {
+		return "", err
+	}
+
+	for _, p := range m.Projects {
+		if p.Name == project {
+			return p.Revision, nil
+		}
+	}
+
+	return "", fmt.Errorf("project %q is not found in the jiri manifest", project)
+}
+
+func getGIStatus(ci *changeInfo) (giStatus, error) {
+	var name string
+
+	switch ci.Project {
+	case "fuchsia":
+		name = "stem"
+	case "topaz":
+		name = "topaz/minimal"
+	case "experiences":
+		name = "flower"
+	default:
+		return giStatusUnknown, nil
+	}
+
+	manifest, err := downloadGIManifest(name)
+	if err != nil {
+		return giStatusUnknown, err
+	}
+
+	_, err = getGIRevision(manifest, ci.Project)
+	if err != nil {
+		return giStatusUnknown, err
+	}
+
+	// TODO: Finish implementation.
+	return giStatusPassed, nil
+}
diff --git a/tools/whereiscl/gistatus_test.go b/tools/whereiscl/gistatus_test.go
new file mode 100644
index 0000000..a01f482
--- /dev/null
+++ b/tools/whereiscl/gistatus_test.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+	"encoding/base64"
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestGetGIStatus(t *testing.T) {
+	body := base64.StdEncoding.EncodeToString([]byte(`
+<manifest>
+  <projects>
+    <project name="fuchsia"
+             revision="gi_revision"/>
+  </projects>
+</manifest>
+`))
+	http.DefaultClient.Transport = &mockTransport{body: body}
+
+	ci := changeInfo{Project: "fuchsia"}
+	got, err := getGIStatus(&ci)
+	if err != nil {
+		t.Fatalf("getGIStatus: %v", err)
+	}
+	// TODO: Update this after finishing implementation of giStatus().
+	var want giStatus = "PASSED"
+	if d := cmp.Diff(want, got); d != "" {
+		t.Errorf("getGIStatus: mismatch (-want +got):\n%s", d)
+	}
+}
diff --git a/tools/whereiscl/whereiscl.go b/tools/whereiscl/whereiscl.go
index ec92b6d..ab8cd58 100644
--- a/tools/whereiscl/whereiscl.go
+++ b/tools/whereiscl/whereiscl.go
@@ -5,17 +5,14 @@
 package main
 
 import (
-	"encoding/json"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"log"
-	"net/http"
 	"os"
 	"regexp"
 )
 
-const fuchsiaURL = "https://fuchsia-review.googlesource.com"
+const fuchsiaReviewURL = "https://fuchsia-review.googlesource.com"
 
 // fuchsiaRE is a regexp for matching CL review URLs and extracting the CL numbers.
 // Supports various forms. E.g.,
@@ -25,13 +22,11 @@
 //   - fxr/123456789/some/file
 var fuchsiaRE = regexp.MustCompile(`^(?:https?://)?(?:fxr|fuchsia-review.googlesource.com/c/.+/\+)/(\d+).*`)
 
-type queryInfo struct{ apiEndpoint, cl string }
-
 func parseReviewURL(str string) (queryInfo, error) {
 	match := fuchsiaRE.FindStringSubmatch(str)
 	if match != nil {
 		return queryInfo{
-			apiEndpoint: fuchsiaURL,
+			apiEndpoint: fuchsiaReviewURL,
 			cl:          match[1],
 		}, nil
 	}
@@ -39,43 +34,6 @@
 	return queryInfo{}, errors.New("not a valid review URL")
 }
 
-// changeInfo is a JSON struct for ChangeInfo responses from Gerrit.
-// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info.
-// Only fields of interest are listed here.
-type changeInfo struct {
-	Status string `json:"status"`
-}
-
-func getCLStatus(qi queryInfo) (string, error) {
-	query := fmt.Sprintf("%s/changes/?q=%s", qi.apiEndpoint, qi.cl)
-	resp, err := http.Get(query)
-	if err != nil {
-		return "", err
-	}
-	defer resp.Body.Close()
-
-	b, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		return "", err
-	}
-
-	var cis []changeInfo
-	// Responses start with )]}' to prevent XSSI attacks. Discard them.
-	// See https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
-	if err := json.Unmarshal(b[4:], &cis); err != nil {
-		return "", err
-	}
-
-	switch len(cis) {
-	case 0:
-		return "", errors.New("CL not found")
-	case 1:
-		return cis[0].Status, nil
-	default:
-		return "", fmt.Errorf("Got %d CLs while expecting only one", len(cis))
-	}
-}
-
 func main() {
 	if len(os.Args) < 2 {
 		// TODO: Consider alternatives. E.g., show all outstanding CLs
@@ -89,9 +47,19 @@
 		log.Fatalf("Error parsing the review URL: %v", err)
 	}
 
-	status, err := getCLStatus(queryInfo)
+	ci, err := getChangeInfo(queryInfo)
 	if err != nil {
 		log.Fatalf("Error getting change info: %v", err)
 	}
-	fmt.Printf("CL status: %v\n", status)
+	fmt.Printf("CL status: %v\n", ci.Status)
+
+	if ci.Status != clStatusMerged {
+		return
+	}
+
+	gs, err := getGIStatus(ci)
+	if err != nil {
+		log.Fatalf("Error getting GI status: %v", err)
+	}
+	fmt.Printf("GI status: %v\n", gs)
 }
diff --git a/tools/whereiscl/whereiscl_test.go b/tools/whereiscl/whereiscl_test.go
index 09d14aa..d0846cd0 100644
--- a/tools/whereiscl/whereiscl_test.go
+++ b/tools/whereiscl/whereiscl_test.go
@@ -55,71 +55,7 @@
 	}
 }
 
-func TestGetCLStatus(t *testing.T) {
-	cl := "987654321"
-	http.DefaultClient.Transport = &mockTransport{
-		cl: cl,
-		body: `)]}'
-[
-  {
-    "foo": 42,
-    "status": "MERGED"
-  }
-]`,
-	}
-	got, err := getCLStatus(queryInfo{
-		apiEndpoint: "https://fuchsia-review.googlesource.com",
-		cl:          cl,
-	})
-	if err != nil {
-		t.Fatalf("getCLStatus: %v", err)
-	}
-	want := "MERGED"
-	if d := cmp.Diff(want, got); d != "" {
-		t.Errorf("getCLStatus: mismatch (-want +got):\n%s", d)
-	}
-}
-
-func TestGetCLStatus_clNotFound(t *testing.T) {
-	cl := "987654321"
-	http.DefaultClient.Transport = &mockTransport{
-		cl: cl,
-		body: `)]}'
-[]`,
-	}
-	_, err := getCLStatus(queryInfo{
-		apiEndpoint: "https://fuchsia-review.googlesource.com",
-		cl:          cl,
-	})
-	if err == nil {
-		t.Error("getCLStatus: error expected; got nil")
-	}
-}
-
-func TestGetCLStatus_tooManyCLs(t *testing.T) {
-	cl := "987654321"
-	http.DefaultClient.Transport = &mockTransport{
-		cl: cl,
-		body: `)]}'
-[
-  {
-    "status": "ACTIVE"
-  },
-  {
-    "status": "MERGED"
-  }
-]`,
-	}
-	_, err := getCLStatus(queryInfo{
-		apiEndpoint: "https://fuchsia-review.googlesource.com",
-		cl:          cl,
-	})
-	if err == nil {
-		t.Error("getCLStatus: error expected; got nil")
-	}
-}
-
-// Fake http transport.
+// Fake http transport. This is used from other test files.
 type mockTransport struct{ cl, body string }
 
 func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -132,7 +68,12 @@
 				log.Fatalf("RoundTrip: invalid CL: %s", cl)
 			}
 		default:
-			log.Fatalf("RoundTrip: invalid path: %s", req.URL.Path)
+			log.Fatalf("RoundTrip: invalid changes path: %s", req.URL.Path)
+		}
+	case "fuchsia.googlesource.com":
+		manifestPrefix := "/integration/+/refs/heads/master/"
+		if !strings.HasPrefix(req.URL.Path, manifestPrefix) {
+			log.Fatalf("RoundTrip: invalid manifest path: %s", req.URL.Path)
 		}
 	default:
 		log.Fatalf("RoundTrip: invalid hostname: %s", req.URL.Hostname())