devtools/{depotview,hackdoc}: tie both together

Change-Id: I0a1ca3b4fa0e0a074eccbe0f8748839b926db9c1
diff --git a/devtools/depotview/service/service.go b/devtools/depotview/service/service.go
index 910bf36..fad2029 100644
--- a/devtools/depotview/service/service.go
+++ b/devtools/depotview/service/service.go
@@ -2,6 +2,7 @@
 
 import (
 	"context"
+	"fmt"
 	"io"
 	"regexp"
 	"strings"
@@ -13,6 +14,7 @@
 	"google.golang.org/grpc/status"
 
 	git "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/config"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/filemode"
 	"github.com/go-git/go-git/v5/plumbing/object"
@@ -30,9 +32,9 @@
 	remote string
 	storer storage.Storer
 
-	mu       sync.Mutex
-	repo     *git.Repository
-	lastPull time.Time
+	mu        sync.Mutex
+	repo      *git.Repository
+	lastFetch time.Time
 }
 
 func New(remote string) *Service {
@@ -42,10 +44,10 @@
 	}
 }
 
-func (s *Service) ensureRepo() error {
+func (s *Service) ensureRepo(ctx context.Context) error {
 	// Clone repository if necessary.
 	if s.repo == nil {
-		repo, err := git.Clone(s.storer, nil, &git.CloneOptions{
+		repo, err := git.CloneContext(ctx, s.storer, nil, &git.CloneOptions{
 			URL: s.remote,
 		})
 		if err != nil {
@@ -53,17 +55,24 @@
 			return status.Error(codes.Unavailable, "could not clone repository")
 		}
 		s.repo = repo
-		s.lastPull = time.Now()
 	}
 
 	// Fetch if necessary.
-	if time.Since(s.lastPull) > time.Minute {
-		err := s.repo.Fetch(&git.FetchOptions{})
+	if time.Since(s.lastFetch) > 10*time.Second {
+		glog.Infof("Fetching...")
+		err := s.repo.FetchContext(ctx, &git.FetchOptions{
+			RefSpecs: []config.RefSpec{
+				config.RefSpec("+refs/heads/*:refs/remotes/origin/*"),
+				config.RefSpec("+refs/changes/*:refs/changes/*"),
+			},
+			Force: true,
+		})
 		if err != nil && err != git.NoErrAlreadyUpToDate {
 			glog.Errorf("Fetch(): %v", err)
 		} else {
-			s.lastPull = time.Now()
+			s.lastFetch = time.Now()
 		}
+
 	}
 
 	return nil
@@ -77,22 +86,69 @@
 		return nil, status.Error(codes.InvalidArgument, "ref must be set")
 	}
 
-	if err := s.ensureRepo(); err != nil {
+	if err := s.ensureRepo(ctx); err != nil {
 		return nil, err
 	}
 
 	h, err := s.repo.ResolveRevision(plumbing.Revision(req.Ref))
 	switch {
 	case err == plumbing.ErrReferenceNotFound:
-		return &pb.ResolveResponse{Hash: "", LastChecked: s.lastPull.UnixNano()}, nil
+		return &pb.ResolveResponse{Hash: "", LastChecked: s.lastFetch.UnixNano()}, nil
 	case err != nil:
 		return nil, status.Errorf(codes.Unavailable, "git resolve error: %v", err)
 	default:
-		return &pb.ResolveResponse{Hash: h.String(), LastChecked: s.lastPull.UnixNano()}, nil
+		return &pb.ResolveResponse{Hash: h.String(), LastChecked: s.lastFetch.UnixNano()}, nil
 	}
 }
 
-func (s *Service) getFile(hash, path string, notFoundOkay bool) (*object.File, error) {
+func (s *Service) ResolveGerritChange(ctx context.Context, req *pb.ResolveGerritChangeRequest) (*pb.ResolveGerritChangeResponse, error) {
+	if err := s.ensureRepo(ctx); err != nil {
+		return nil, err
+	}
+
+	// I'm totally guessing this, from these examples:
+	//    refs/changes/03/3/meta
+	//    refs/changes/77/77/meta
+	//    refs/changes/47/247/meta
+	// etc...
+	shard := fmt.Sprintf("%02d", req.Change%100)
+	metaRef := fmt.Sprintf("refs/changes/%s/%d/meta", shard, req.Change)
+
+	h, err := s.repo.ResolveRevision(plumbing.Revision(metaRef))
+	switch {
+	case err == plumbing.ErrReferenceNotFound:
+		return &pb.ResolveGerritChangeResponse{Hash: "", LastChecked: s.lastFetch.UnixNano()}, nil
+	case err != nil:
+		return nil, status.Errorf(codes.Unavailable, "git metadata resolve error: %v", err)
+	}
+
+	c, err := s.repo.CommitObject(*h)
+	if err != nil {
+		return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
+	}
+
+	var messages []string
+	for {
+		messages = append([]string{c.Message}, messages...)
+
+		if len(c.ParentHashes) != 1 {
+			break
+		}
+
+		c, err = s.repo.CommitObject(c.ParentHashes[0])
+		if err != nil {
+			return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
+		}
+	}
+
+	meta := parseGerritMetadata(messages)
+	if meta == nil {
+		return nil, status.Errorf(codes.Internal, "could not parse gerrit metadata for ref %q", metaRef)
+	}
+	return &pb.ResolveGerritChangeResponse{Hash: meta.commit, LastChecked: s.lastFetch.UnixNano()}, nil
+}
+
+func (s *Service) getFile(ctx context.Context, hash, path string, notFoundOkay bool) (*object.File, error) {
 	if !reHash.MatchString(hash) {
 		return nil, status.Error(codes.InvalidArgument, "hash must be valid full git hash string")
 	}
@@ -105,7 +161,7 @@
 		return nil, status.Error(codes.InvalidArgument, "path must be a valid unix or depot-style path")
 	}
 
-	if err := s.ensureRepo(); err != nil {
+	if err := s.ensureRepo(ctx); err != nil {
 		return nil, err
 	}
 
@@ -134,7 +190,7 @@
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	file, err := s.getFile(req.Hash, req.Path, true)
+	file, err := s.getFile(ctx, req.Hash, req.Path, true)
 	if err != nil {
 		return nil, err
 	}
@@ -157,7 +213,9 @@
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	file, err := s.getFile(req.Hash, req.Path, false)
+	ctx := srv.Context()
+
+	file, err := s.getFile(ctx, req.Hash, req.Path, false)
 	if err != nil {
 		return err
 	}
@@ -168,7 +226,6 @@
 	}
 	defer reader.Close()
 
-	ctx := srv.Context()
 	for {
 		if ctx.Err() != nil {
 			return ctx.Err()