devtools/{depotview,hackdoc}: tie both together

Change-Id: I0a1ca3b4fa0e0a074eccbe0f8748839b926db9c1
diff --git a/devtools/depotview/proto/depotview.proto b/devtools/depotview/proto/depotview.proto
index 2584961..b948fae 100644
--- a/devtools/depotview/proto/depotview.proto
+++ b/devtools/depotview/proto/depotview.proto
@@ -2,6 +2,18 @@
 package depotview;
 option go_package = "code.hackerspace.pl/hscloud/devtools/depotview/proto";
 
+service DepotView {
+    // Resolve a git branch/tag/ref... into a commit hash.
+    rpc Resolve(ResolveRequest) returns (ResolveResponse);
+
+    // Resolve a gerrit change number into a git commit hash.
+    rpc ResolveGerritChange(ResolveGerritChangeRequest) returns (ResolveGerritChangeResponse);
+        
+    // Minimal file access API. It kinda stinks.
+    rpc Stat(StatRequest) returns (StatResponse);
+    rpc Read(ReadRequest) returns (stream ReadResponse);
+}
+
 message ResolveRequest {
     string ref = 1;
 }
@@ -11,6 +23,15 @@
     int64 last_checked = 2;
 }
 
+message ResolveGerritChangeRequest {
+    int64 change = 1;
+}
+
+message ResolveGerritChangeResponse {
+    string hash = 1;
+    int64 last_checked = 2;
+}
+
 message StatRequest {
     string hash = 1;
     string path = 2;
@@ -35,12 +56,3 @@
     // Chunk of data. Empty once everything has been sent over.
     bytes data = 1;
 }
-
-service DepotView {
-    // Resolve a git branch/tag/ref... into a commit hash.
-    rpc Resolve(ResolveRequest) returns (ResolveResponse);
-        
-    // Minimal file access API. It kinda stinks.
-    rpc Stat(StatRequest) returns (StatResponse);
-    rpc Read(ReadRequest) returns (stream ReadResponse);
-}
diff --git a/devtools/depotview/service/BUILD.bazel b/devtools/depotview/service/BUILD.bazel
index 6f7337c..056ec30 100644
--- a/devtools/depotview/service/BUILD.bazel
+++ b/devtools/depotview/service/BUILD.bazel
@@ -2,12 +2,16 @@
 
 go_library(
     name = "go_default_library",
-    srcs = ["service.go"],
+    srcs = [
+        "gerrit.go",
+        "service.go",
+    ],
     importpath = "code.hackerspace.pl/hscloud/devtools/depotview/service",
     visibility = ["//visibility:public"],
     deps = [
         "//devtools/depotview/proto:go_default_library",
         "@com_github_go_git_go_git_v5//:go_default_library",
+        "@com_github_go_git_go_git_v5//config:go_default_library",
         "@com_github_go_git_go_git_v5//plumbing:go_default_library",
         "@com_github_go_git_go_git_v5//plumbing/filemode:go_default_library",
         "@com_github_go_git_go_git_v5//plumbing/object:go_default_library",
diff --git a/devtools/depotview/service/gerrit.go b/devtools/depotview/service/gerrit.go
new file mode 100644
index 0000000..7dab9e6
--- /dev/null
+++ b/devtools/depotview/service/gerrit.go
@@ -0,0 +1,56 @@
+package service
+
+import (
+	"strconv"
+	"strings"
+)
+
+type gerritMeta struct {
+	patchSet int64
+	changeId string
+	commit   string
+}
+
+// parseGerritMetadata takes a NoteDB metadata entry and extracts info from it.
+func parseGerritMetadata(messages []string) *gerritMeta {
+	meta := &gerritMeta{}
+
+	for _, message := range messages {
+		for _, line := range strings.Split(message, "\n") {
+			line = strings.TrimSpace(line)
+			if len(line) == 0 {
+				continue
+			}
+
+			parts := strings.SplitN(line, ":", 2)
+			if len(parts) < 2 {
+				continue
+			}
+			k, v := parts[0], strings.TrimSpace(parts[1])
+
+			switch k {
+			case "Patch-set":
+				n, err := strconv.ParseInt(v, 10, 64)
+				if err != nil {
+					continue
+				}
+				meta.patchSet = n
+			case "Change-id":
+				meta.changeId = v
+			case "Commit":
+				meta.commit = v
+			}
+		}
+	}
+
+	if meta.patchSet == 0 {
+		return nil
+	}
+	if meta.changeId == "" {
+		return nil
+	}
+	if meta.commit == "" {
+		return nil
+	}
+	return meta
+}
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()