about summary refs log tree commit diff
path: root/third_party/go/git-appraise/review
diff options
context:
space:
mode:
Diffstat (limited to 'third_party/go/git-appraise/review')
-rw-r--r--third_party/go/git-appraise/review/analyses/analyses.go160
-rw-r--r--third_party/go/git-appraise/review/analyses/analyses_test.go77
-rw-r--r--third_party/go/git-appraise/review/ci/ci.go95
-rw-r--r--third_party/go/git-appraise/review/ci/ci_test.go85
-rw-r--r--third_party/go/git-appraise/review/comment/comment.go266
-rw-r--r--third_party/go/git-appraise/review/gpg/signable.go129
-rw-r--r--third_party/go/git-appraise/review/request/request.go104
-rw-r--r--third_party/go/git-appraise/review/review.go772
-rw-r--r--third_party/go/git-appraise/review/review_test.go870
9 files changed, 2558 insertions, 0 deletions
diff --git a/third_party/go/git-appraise/review/analyses/analyses.go b/third_party/go/git-appraise/review/analyses/analyses.go
new file mode 100644
index 0000000000..4828f3b230
--- /dev/null
+++ b/third_party/go/git-appraise/review/analyses/analyses.go
@@ -0,0 +1,160 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 analyses defines the internal representation of static analysis reports.
+package analyses
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"sort"
+	"strconv"
+
+	"github.com/google/git-appraise/repository"
+)
+
+const (
+	// Ref defines the git-notes ref that we expect to contain analysis reports.
+	Ref = "refs/notes/devtools/analyses"
+
+	// StatusLooksGoodToMe is the status string representing that analyses reported no messages.
+	StatusLooksGoodToMe = "lgtm"
+	// StatusForYourInformation is the status string representing that analyses reported informational messages.
+	StatusForYourInformation = "fyi"
+	// StatusNeedsMoreWork is the status string representing that analyses reported error messages.
+	StatusNeedsMoreWork = "nmw"
+
+	// FormatVersion defines the latest version of the request format supported by the tool.
+	FormatVersion = 0
+)
+
+// Report represents a build/test status report generated by analyses tool.
+// Every field is optional.
+type Report struct {
+	Timestamp string `json:"timestamp,omitempty"`
+	URL       string `json:"url,omitempty"`
+	Status    string `json:"status,omitempty"`
+	// Version represents the version of the metadata format.
+	Version int `json:"v,omitempty"`
+}
+
+// LocationRange represents the location within a source file that an analysis message covers.
+type LocationRange struct {
+	StartLine   uint32 `json:"start_line,omitempty"`
+	StartColumn uint32 `json:"start_column,omitempty"`
+	EndLine     uint32 `json:"end_line,omitempty"`
+	EndColumn   uint32 `json:"end_column,omitempty"`
+}
+
+// Location represents the location within a source tree that an analysis message covers.
+type Location struct {
+	Path  string         `json:"path,omitempty"`
+	Range *LocationRange `json:"range,omitempty"`
+}
+
+// Note represents a single analysis message.
+type Note struct {
+	Location    *Location `json:"location,omitempty"`
+	Category    string    `json:"category,omitempty"`
+	Description string    `json:"description"`
+}
+
+// AnalyzeResponse represents the response from a static-analysis tool.
+type AnalyzeResponse struct {
+	Notes []Note `json:"note,omitempty"`
+}
+
+// ReportDetails represents an entire static analysis run (which might include multiple analysis tools).
+type ReportDetails struct {
+	AnalyzeResponse []AnalyzeResponse `json:"analyze_response,omitempty"`
+}
+
+// GetLintReportResult downloads the details of a lint report and returns the responses embedded in it.
+func (analysesReport Report) GetLintReportResult() ([]AnalyzeResponse, error) {
+	if analysesReport.URL == "" {
+		return nil, nil
+	}
+	res, err := http.Get(analysesReport.URL)
+	if err != nil {
+		return nil, err
+	}
+	analysesResults, err := ioutil.ReadAll(res.Body)
+	res.Body.Close()
+	if err != nil {
+		return nil, err
+	}
+	var details ReportDetails
+	err = json.Unmarshal([]byte(analysesResults), &details)
+	if err != nil {
+		return nil, err
+	}
+	return details.AnalyzeResponse, nil
+}
+
+// GetNotes downloads the details of an analyses report and returns the notes embedded in it.
+func (analysesReport Report) GetNotes() ([]Note, error) {
+	reportResults, err := analysesReport.GetLintReportResult()
+	if err != nil {
+		return nil, err
+	}
+	var reportNotes []Note
+	for _, reportResult := range reportResults {
+		reportNotes = append(reportNotes, reportResult.Notes...)
+	}
+	return reportNotes, nil
+}
+
+// Parse parses an analysis report from a git note.
+func Parse(note repository.Note) (Report, error) {
+	bytes := []byte(note)
+	var report Report
+	err := json.Unmarshal(bytes, &report)
+	return report, err
+}
+
+// GetLatestAnalysesReport takes a collection of analysis reports, and returns the one with the most recent timestamp.
+func GetLatestAnalysesReport(reports []Report) (*Report, error) {
+	timestampReportMap := make(map[int]*Report)
+	var timestamps []int
+
+	for _, report := range reports {
+		timestamp, err := strconv.Atoi(report.Timestamp)
+		if err != nil {
+			return nil, err
+		}
+		timestamps = append(timestamps, timestamp)
+		timestampReportMap[timestamp] = &report
+	}
+	if len(timestamps) == 0 {
+		return nil, nil
+	}
+	sort.Sort(sort.Reverse(sort.IntSlice(timestamps)))
+	return timestampReportMap[timestamps[0]], nil
+}
+
+// ParseAllValid takes collection of git notes and tries to parse a analyses report
+// from each one. Any notes that are not valid analyses reports get ignored.
+func ParseAllValid(notes []repository.Note) []Report {
+	var reports []Report
+	for _, note := range notes {
+		report, err := Parse(note)
+		if err == nil && report.Version == FormatVersion {
+			reports = append(reports, report)
+		}
+	}
+	return reports
+}
diff --git a/third_party/go/git-appraise/review/analyses/analyses_test.go b/third_party/go/git-appraise/review/analyses/analyses_test.go
new file mode 100644
index 0000000000..00a811ef6a
--- /dev/null
+++ b/third_party/go/git-appraise/review/analyses/analyses_test.go
@@ -0,0 +1,77 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 analyses
+
+import (
+	"fmt"
+	"github.com/google/git-appraise/repository"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+const (
+	mockOldReport = `{"timestamp": "0", "url": "https://this-url-does-not-exist.test/analysis.json"}`
+	mockNewReport = `{"timestamp": "1", "url": "%s"}`
+	mockResults   = `{
+  "analyze_response": [{
+    "note": [{
+      "location": {
+        "path": "file.txt",
+        "range": {
+          "start_line": 5
+        }
+      },
+      "category": "test",
+      "description": "This is a test"
+    }]
+  }]
+}`
+)
+
+func mockHandler(t *testing.T) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		t.Log(r)
+		fmt.Fprintln(w, mockResults)
+		w.WriteHeader(http.StatusOK)
+	}
+}
+
+func TestGetLatestResult(t *testing.T) {
+	mockServer := httptest.NewServer(http.HandlerFunc(mockHandler(t)))
+	defer mockServer.Close()
+
+	reports := ParseAllValid([]repository.Note{
+		repository.Note([]byte(mockOldReport)),
+		repository.Note([]byte(fmt.Sprintf(mockNewReport, mockServer.URL))),
+	})
+
+	report, err := GetLatestAnalysesReport(reports)
+	if err != nil {
+		t.Fatal("Unexpected error while parsing analysis reports", err)
+	}
+	if report == nil {
+		t.Fatal("Unexpected nil report")
+	}
+	reportResult, err := report.GetLintReportResult()
+	if err != nil {
+		t.Fatal("Unexpected error while reading the latest report's results", err)
+	}
+	if len(reportResult) != 1 {
+		t.Fatal("Unexpected report result", reportResult)
+	}
+}
diff --git a/third_party/go/git-appraise/review/ci/ci.go b/third_party/go/git-appraise/review/ci/ci.go
new file mode 100644
index 0000000000..b2cfd22743
--- /dev/null
+++ b/third_party/go/git-appraise/review/ci/ci.go
@@ -0,0 +1,95 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 ci defines the internal representation of a continuous integration reports.
+package ci
+
+import (
+	"encoding/json"
+	"github.com/google/git-appraise/repository"
+	"sort"
+	"strconv"
+)
+
+const (
+	// Ref defines the git-notes ref that we expect to contain CI reports.
+	Ref = "refs/notes/devtools/ci"
+
+	// StatusSuccess is the status string representing that a build and/or test passed.
+	StatusSuccess = "success"
+	// StatusFailure is the status string representing that a build and/or test failed.
+	StatusFailure = "failure"
+
+	// FormatVersion defines the latest version of the request format supported by the tool.
+	FormatVersion = 0
+)
+
+// Report represents a build/test status report generated by a continuous integration tool.
+//
+// Every field is optional.
+type Report struct {
+	Timestamp string `json:"timestamp,omitempty"`
+	URL       string `json:"url,omitempty"`
+	Status    string `json:"status,omitempty"`
+	Agent     string `json:"agent,omitempty"`
+	// Version represents the version of the metadata format.
+	Version int `json:"v,omitempty"`
+}
+
+// Parse parses a CI report from a git note.
+func Parse(note repository.Note) (Report, error) {
+	bytes := []byte(note)
+	var report Report
+	err := json.Unmarshal(bytes, &report)
+	return report, err
+}
+
+// GetLatestCIReport takes the collection of reports and returns the one with the most recent timestamp.
+func GetLatestCIReport(reports []Report) (*Report, error) {
+	timestampReportMap := make(map[int]*Report)
+	var timestamps []int
+
+	for _, report := range reports {
+		timestamp, err := strconv.Atoi(report.Timestamp)
+		if err != nil {
+			return nil, err
+		}
+		timestamps = append(timestamps, timestamp)
+		timestampReportMap[timestamp] = &report
+	}
+	if len(timestamps) == 0 {
+		return nil, nil
+	}
+	sort.Sort(sort.Reverse(sort.IntSlice(timestamps)))
+	return timestampReportMap[timestamps[0]], nil
+}
+
+// ParseAllValid takes collection of git notes and tries to parse a CI report
+// from each one. Any notes that are not valid CI reports get ignored, as we
+// expect the git notes to be a heterogenous list, with only some of them
+// being valid CI status reports.
+func ParseAllValid(notes []repository.Note) []Report {
+	var reports []Report
+	for _, note := range notes {
+		report, err := Parse(note)
+		if err == nil && report.Version == FormatVersion {
+			if report.Status == "" || report.Status == StatusSuccess || report.Status == StatusFailure {
+				reports = append(reports, report)
+			}
+		}
+	}
+	return reports
+}
diff --git a/third_party/go/git-appraise/review/ci/ci_test.go b/third_party/go/git-appraise/review/ci/ci_test.go
new file mode 100644
index 0000000000..c141f053d9
--- /dev/null
+++ b/third_party/go/git-appraise/review/ci/ci_test.go
@@ -0,0 +1,85 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 ci
+
+import (
+	"github.com/google/git-appraise/repository"
+	"testing"
+)
+
+const testCINote1 = `{
+	"Timestamp": "4",
+	"URL": "www.google.com",
+	"Status": "success"
+}`
+
+const testCINote2 = `{
+	"Timestamp": "16",
+	"URL": "www.google.com",
+	"Status": "failure"
+}`
+
+const testCINote3 = `{
+	"Timestamp": "30",
+	"URL": "www.google.com",
+	"Status": "something else"
+}`
+
+const testCINote4 = `{
+	"Timestamp": "28",
+	"URL": "www.google.com",
+	"Status": "success"
+}`
+
+const testCINote5 = `{
+	"Timestamp": "27",
+	"URL": "www.google.com",
+	"Status": "success"
+}`
+
+func TestCIReport(t *testing.T) {
+	latestReport, err := GetLatestCIReport(ParseAllValid([]repository.Note{
+		repository.Note(testCINote1),
+		repository.Note(testCINote2),
+	}))
+	if err != nil {
+		t.Fatal("Failed to properly fetch the latest report", err)
+	}
+	expected, err := Parse(repository.Note(testCINote2))
+	if err != nil {
+		t.Fatal("Failed to parse the expected report", err)
+	}
+	if *latestReport != expected {
+		t.Fatal("This is not the latest ", latestReport)
+	}
+	latestReport, err = GetLatestCIReport(ParseAllValid([]repository.Note{
+		repository.Note(testCINote1),
+		repository.Note(testCINote2),
+		repository.Note(testCINote3),
+		repository.Note(testCINote4),
+	}))
+	if err != nil {
+		t.Fatal("Failed to properly fetch the latest report", err)
+	}
+	expected, err = Parse(repository.Note(testCINote4))
+	if err != nil {
+		t.Fatal("Failed to parse the expected report", err)
+	}
+	if *latestReport != expected {
+		t.Fatal("This is not the latest ", latestReport)
+	}
+}
diff --git a/third_party/go/git-appraise/review/comment/comment.go b/third_party/go/git-appraise/review/comment/comment.go
new file mode 100644
index 0000000000..b1dea49c13
--- /dev/null
+++ b/third_party/go/git-appraise/review/comment/comment.go
@@ -0,0 +1,266 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 comment defines the internal representation of a review comment.
+package comment
+
+import (
+	"crypto/sha1"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/google/git-appraise/repository"
+	"github.com/google/git-appraise/review/gpg"
+)
+
+// Ref defines the git-notes ref that we expect to contain review comments.
+const Ref = "refs/notes/devtools/discuss"
+
+// FormatVersion defines the latest version of the comment format supported by the tool.
+const FormatVersion = 0
+
+// ErrInvalidRange inidcates an error during parsing of a user-defined file
+// range
+var ErrInvalidRange = errors.New("invalid file location range. The required form is StartLine[+StartColumn][:EndLine[+EndColumn]]. The first line in a file is considered to be line 1")
+
+// Range represents the range of text that is under discussion.
+type Range struct {
+	StartLine   uint32 `json:"startLine"`
+	StartColumn uint32 `json:"startColumn,omitempty"`
+	EndLine     uint32 `json:"endLine,omitempty"`
+	EndColumn   uint32 `json:"endColumn,omitempty"`
+}
+
+// Location represents the location of a comment within a commit.
+type Location struct {
+	Commit string `json:"commit,omitempty"`
+	// If the path is omitted, then the comment applies to the entire commit.
+	Path string `json:"path,omitempty"`
+	// If the range is omitted, then the location represents an entire file.
+	Range *Range `json:"range,omitempty"`
+}
+
+// Check verifies that this location is valid in the provided
+// repository.
+func (location *Location) Check(repo repository.Repo) error {
+	contents, err := repo.Show(location.Commit, location.Path)
+	if err != nil {
+		return err
+	}
+	lines := strings.Split(contents, "\n")
+	if location.Range.StartLine > uint32(len(lines)) {
+		return fmt.Errorf("Line number %d does not exist in file %q",
+			location.Range.StartLine,
+			location.Path)
+	}
+	if location.Range.StartColumn != 0 &&
+		location.Range.StartColumn > uint32(len(lines[location.Range.StartLine-1])) {
+		return fmt.Errorf("Line %d in %q is too short for column %d",
+			location.Range.StartLine,
+			location.Path,
+			location.Range.StartColumn)
+	}
+	if location.Range.EndLine != 0 &&
+		location.Range.EndLine > uint32(len(lines)) {
+		return fmt.Errorf("End line number %d does not exist in file %q",
+			location.Range.EndLine,
+			location.Path)
+	}
+	if location.Range.EndColumn != 0 &&
+		location.Range.EndColumn > uint32(len(lines[location.Range.EndLine-1])) {
+		return fmt.Errorf("End line %d in %q is too short for column %d",
+			location.Range.EndLine,
+			location.Path,
+			location.Range.EndColumn)
+	}
+	return nil
+}
+
+// Comment represents a review comment, and can occur in any of the following contexts:
+// 1. As a comment on an entire commit.
+// 2. As a comment about a specific file in a commit.
+// 3. As a comment about a specific line in a commit.
+// 4. As a response to another comment.
+type Comment struct {
+	// Timestamp and Author are optimizations that allows us to display comment threads
+	// without having to run git-blame over the notes object. This is done because
+	// git-blame will become more and more expensive as the number of code reviews grows.
+	Timestamp string `json:"timestamp,omitempty"`
+	Author    string `json:"author,omitempty"`
+	// If original is provided, then the comment is an updated version of another comment.
+	Original string `json:"original,omitempty"`
+	// If parent is provided, then the comment is a response to another comment.
+	Parent string `json:"parent,omitempty"`
+	// If location is provided, then the comment is specific to that given location.
+	Location    *Location `json:"location,omitempty"`
+	Description string    `json:"description,omitempty"`
+	// The resolved bit indicates that no further action is needed.
+	//
+	// When the parent of the comment is another comment, this means that comment
+	// has been addressed. Otherwise, the parent is the commit, and this means that the
+	// change has been accepted. If the resolved bit is unset, then the comment is only an FYI.
+	Resolved *bool `json:"resolved,omitempty"`
+	// Version represents the version of the metadata format.
+	Version int `json:"v,omitempty"`
+
+	gpg.Sig
+}
+
+// New returns a new comment with the given description message.
+//
+// The Timestamp and Author fields are automatically filled in with the current time and user.
+func New(author string, description string) Comment {
+	return Comment{
+		Timestamp:   strconv.FormatInt(time.Now().Unix(), 10),
+		Author:      author,
+		Description: description,
+	}
+}
+
+// Parse parses a review comment from a git note.
+func Parse(note repository.Note) (Comment, error) {
+	bytes := []byte(note)
+	var comment Comment
+	err := json.Unmarshal(bytes, &comment)
+	return comment, err
+}
+
+// ParseAllValid takes collection of git notes and tries to parse a review
+// comment from each one. Any notes that are not valid review comments get
+// ignored, as we expect the git notes to be a heterogenous list, with only
+// some of them being review comments.
+func ParseAllValid(notes []repository.Note) map[string]Comment {
+	comments := make(map[string]Comment)
+	for _, note := range notes {
+		comment, err := Parse(note)
+		if err == nil && comment.Version == FormatVersion {
+			hash, err := comment.Hash()
+			if err == nil {
+				comments[hash] = comment
+			}
+		}
+	}
+	return comments
+}
+
+func (comment Comment) serialize() ([]byte, error) {
+	if len(comment.Timestamp) < 10 {
+		// To make sure that timestamps from before 2001 appear in the correct
+		// alphabetical order, we reformat the timestamp to be at least 10 characters
+		// and zero-padded.
+		time, err := strconv.ParseInt(comment.Timestamp, 10, 64)
+		if err == nil {
+			comment.Timestamp = fmt.Sprintf("%010d", time)
+		}
+		// We ignore the other case, as the comment timestamp is not in a format
+		// we expected, so we should just leave it alone.
+	}
+	return json.Marshal(comment)
+}
+
+// Write writes a review comment as a JSON-formatted git note.
+func (comment Comment) Write() (repository.Note, error) {
+	bytes, err := comment.serialize()
+	return repository.Note(bytes), err
+}
+
+// Hash returns the SHA1 hash of a review comment.
+func (comment Comment) Hash() (string, error) {
+	bytes, err := comment.serialize()
+	return fmt.Sprintf("%x", sha1.Sum(bytes)), err
+}
+
+// Set implenents flag.Value for the Range type
+func (r *Range) Set(s string) error {
+	var err error
+	*r = Range{}
+
+	if s == "" {
+		return nil
+	}
+	startEndParts := strings.Split(s, ":")
+	if len(startEndParts) > 2 {
+		return ErrInvalidRange
+	}
+
+	r.StartLine, r.StartColumn, err = parseRangePart(startEndParts[0])
+	if err != nil {
+		return err
+	}
+	if len(startEndParts) == 1 {
+		return nil
+	}
+
+	r.EndLine, r.EndColumn, err = parseRangePart(startEndParts[1])
+	if err != nil {
+		return err
+	}
+
+	if r.StartLine > r.EndLine {
+		return errors.New("start line cannot be greater than end line in range")
+	}
+
+	return nil
+}
+
+func parseRangePart(s string) (uint32, uint32, error) {
+	parts := strings.Split(s, "+")
+	if len(parts) > 2 {
+		return 0, 0, ErrInvalidRange
+	}
+
+	line, err := strconv.ParseUint(parts[0], 10, 32)
+	if err != nil {
+		return 0, 0, ErrInvalidRange
+	}
+
+	if len(parts) == 1 {
+		return uint32(line), 0, nil
+	}
+
+	col, err := strconv.ParseUint(parts[1], 10, 32)
+	if err != nil {
+		return 0, 0, ErrInvalidRange
+	}
+
+	if line == 0 && col != 0 {
+		// line 0 represents the entire file
+		return 0, 0, ErrInvalidRange
+	}
+
+	return uint32(line), uint32(col), nil
+}
+
+func (r *Range) String() string {
+	out := ""
+	if r.StartLine != 0 {
+		out = fmt.Sprintf("%d", r.StartLine)
+	}
+	if r.StartColumn != 0 {
+		out = fmt.Sprintf("%s+%d", out, r.StartColumn)
+	}
+	if r.EndLine != 0 {
+		out = fmt.Sprintf("%s:%d", out, r.EndLine)
+	}
+	if r.EndColumn != 0 {
+		out = fmt.Sprintf("%s+%d", out, r.EndColumn)
+	}
+	return out
+}
diff --git a/third_party/go/git-appraise/review/gpg/signable.go b/third_party/go/git-appraise/review/gpg/signable.go
new file mode 100644
index 0000000000..776764c6fc
--- /dev/null
+++ b/third_party/go/git-appraise/review/gpg/signable.go
@@ -0,0 +1,129 @@
+// Package gpg provides an interface and an abstraction with which to sign and
+// verify review requests and comments.
+package gpg
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+)
+
+const placeholder = "gpgsig"
+
+// Sig provides an abstraction around shelling out to GPG to sign the
+// content it's given.
+type Sig struct {
+	// Sig holds an object's content's signature.
+	Sig string `json:"signature,omitempty"`
+}
+
+// Signable is an interfaces which provides the pointer to the signable
+// object's stringified signature.
+//
+// This pointer is used by `Sign` and `Verify` to replace its contents with
+// `placeholder` or the signature itself for the purposes of signing or
+// verifying.
+type Signable interface {
+	Signature() *string
+}
+
+// Signature is `Sig`'s implementation of `Signable`. Through this function, an
+// object which needs to implement `Signable` need only embed `Sig`
+// anonymously. See, e.g., review/request.go.
+func (s *Sig) Signature() *string {
+	return &s.Sig
+}
+
+// Sign uses gpg to sign the contents of a request and deposit it into the
+// signature key of the request.
+func Sign(key string, s Signable) error {
+	// First we retrieve the pointer and write `placeholder` as its value.
+	sigPtr := s.Signature()
+	*sigPtr = placeholder
+
+	// Marshal the content and sign it.
+	content, err := json.Marshal(s)
+	if err != nil {
+		return err
+	}
+	sig, err := signContent(key, content)
+	if err != nil {
+		return err
+	}
+
+	// Write the signature as the new value at the pointer.
+	*sigPtr = sig.String()
+	return nil
+}
+
+func signContent(key string, content []byte) (*bytes.Buffer,
+	error) {
+	var stdout, stderr bytes.Buffer
+	cmd := exec.Command("gpg", "-u", key, "--detach-sign", "--armor")
+	cmd.Stdin = bytes.NewReader(content)
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	err := cmd.Run()
+	return &stdout, err
+}
+
+// Verify verifies the signatures on the request and its comments with the
+// given key.
+func Verify(s Signable) error {
+	// Retrieve the pointer.
+	sigPtr := s.Signature()
+	// Copy its contents.
+	sig := *sigPtr
+	// Overwrite the value with the placeholder.
+	*sigPtr = placeholder
+
+	defer func() { *sigPtr = sig }()
+
+	// 1. Marshal the content into JSON.
+	// 2. Write the signature and the content to temp files.
+	// 3. Use gpg to verify the signature.
+	content, err := json.Marshal(s)
+	if err != nil {
+		return err
+	}
+	sigFile, err := ioutil.TempFile("", "sig")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(sigFile.Name())
+	_, err = sigFile.Write([]byte(sig))
+	if err != nil {
+		return err
+	}
+	err = sigFile.Close()
+	if err != nil {
+		return err
+	}
+
+	contentFile, err := ioutil.TempFile("", "content")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(contentFile.Name())
+	_, err = contentFile.Write(content)
+	if err != nil {
+		return err
+	}
+	err = contentFile.Close()
+	if err != nil {
+		return err
+	}
+
+	var stdout, stderr bytes.Buffer
+	cmd := exec.Command("gpg", "--verify", sigFile.Name(), contentFile.Name())
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	err = cmd.Run()
+	if err != nil {
+		return fmt.Errorf("%s", stderr.String())
+	}
+	return nil
+}
diff --git a/third_party/go/git-appraise/review/request/request.go b/third_party/go/git-appraise/review/request/request.go
new file mode 100644
index 0000000000..c23fd427a8
--- /dev/null
+++ b/third_party/go/git-appraise/review/request/request.go
@@ -0,0 +1,104 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 request defines the internal representation of a review request.
+package request
+
+import (
+	"encoding/json"
+	"strconv"
+	"time"
+
+	"github.com/google/git-appraise/repository"
+	"github.com/google/git-appraise/review/gpg"
+)
+
+// Ref defines the git-notes ref that we expect to contain review requests.
+const Ref = "refs/notes/devtools/reviews"
+
+// FormatVersion defines the latest version of the request format supported by the tool.
+const FormatVersion = 0
+
+// Request represents an initial request for a code review.
+//
+// Every field is optional.
+type Request struct {
+	// Timestamp and Requester are optimizations that allows us to display reviews
+	// without having to run git-blame over the notes object. This is done because
+	// git-blame will become more and more expensive as the number of reviews grows.
+	Timestamp   string   `json:"timestamp,omitempty"`
+	ReviewRef   string   `json:"reviewRef,omitempty"`
+	TargetRef   string   `json:"targetRef"`
+	Requester   string   `json:"requester,omitempty"`
+	Reviewers   []string `json:"reviewers,omitempty"`
+	Description string   `json:"description,omitempty"`
+	// Version represents the version of the metadata format.
+	Version int `json:"v,omitempty"`
+	// BaseCommit stores the commit ID of the target ref at the time the review was requested.
+	// This is optional, and only used for submitted reviews which were anchored at a merge commit.
+	// This allows someone viewing that submitted review to find the diff against which the
+	// code was reviewed.
+	BaseCommit string `json:"baseCommit,omitempty"`
+	// Alias stores a post-rebase commit ID for the review. This allows the tool
+	// to track the history of a review even if the commit history changes.
+	Alias string `json:"alias,omitempty"`
+
+	gpg.Sig
+}
+
+// New returns a new request.
+//
+// The Timestamp and Requester fields are automatically filled in with the current time and user.
+func New(requester string, reviewers []string, reviewRef, targetRef, description string) Request {
+	return Request{
+		Timestamp:   strconv.FormatInt(time.Now().Unix(), 10),
+		Requester:   requester,
+		Reviewers:   reviewers,
+		ReviewRef:   reviewRef,
+		TargetRef:   targetRef,
+		Description: description,
+	}
+}
+
+// Parse parses a review request from a git note.
+func Parse(note repository.Note) (Request, error) {
+	bytes := []byte(note)
+	var request Request
+	err := json.Unmarshal(bytes, &request)
+	// TODO(ojarjur): If "requester" is not set, then use git-blame to fill it in.
+	return request, err
+}
+
+// ParseAllValid takes collection of git notes and tries to parse a review
+// request from each one. Any notes that are not valid review requests get
+// ignored, as we expect the git notes to be a heterogenous list, with only
+// some of them being review requests.
+func ParseAllValid(notes []repository.Note) []Request {
+	var requests []Request
+	for _, note := range notes {
+		request, err := Parse(note)
+		if err == nil && request.Version == FormatVersion {
+			requests = append(requests, request)
+		}
+	}
+	return requests
+}
+
+// Write writes a review request as a JSON-formatted git note.
+func (request *Request) Write() (repository.Note, error) {
+	bytes, err := json.Marshal(request)
+	return repository.Note(bytes), err
+}
diff --git a/third_party/go/git-appraise/review/review.go b/third_party/go/git-appraise/review/review.go
new file mode 100644
index 0000000000..a23dd17bf7
--- /dev/null
+++ b/third_party/go/git-appraise/review/review.go
@@ -0,0 +1,772 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 review contains the data structures used to represent code reviews.
+package review
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"sort"
+
+	"github.com/google/git-appraise/repository"
+	"github.com/google/git-appraise/review/analyses"
+	"github.com/google/git-appraise/review/ci"
+	"github.com/google/git-appraise/review/comment"
+	"github.com/google/git-appraise/review/gpg"
+	"github.com/google/git-appraise/review/request"
+)
+
+const archiveRef = "refs/devtools/archives/reviews"
+
+// CommentThread represents the tree-based hierarchy of comments.
+//
+// The Resolved field represents the aggregate status of the entire thread. If
+// it is set to false, then it indicates that there is an unaddressed comment
+// in the thread. If it is unset, then that means that the root comment is an
+// FYI only, and that there are no unaddressed comments. If it is set to true,
+// then that means that there are no unaddressed comments, and that the root
+// comment has its resolved bit set to true.
+type CommentThread struct {
+	Hash     string             `json:"hash,omitempty"`
+	Comment  comment.Comment    `json:"comment"`
+	Original *comment.Comment   `json:"original,omitempty"`
+	Edits    []*comment.Comment `json:"edits,omitempty"`
+	Children []CommentThread    `json:"children,omitempty"`
+	Resolved *bool              `json:"resolved,omitempty"`
+	Edited   bool               `json:"edited,omitempty"`
+}
+
+// Summary represents the high-level state of a code review.
+//
+// This high-level state corresponds to the data that can be quickly read
+// directly from the repo, so other methods that need to operate on a lot
+// of reviews (such as listing the open reviews) should prefer operating on
+// the summary rather than the details.
+//
+// Review summaries have two status fields which are orthogonal:
+// 1. Resolved indicates if a reviewer has accepted or rejected the change.
+// 2. Submitted indicates if the change has been incorporated into the target.
+type Summary struct {
+	Repo        repository.Repo   `json:"-"`
+	Revision    string            `json:"revision"`
+	Request     request.Request   `json:"request"`
+	AllRequests []request.Request `json:"-"`
+	Comments    []CommentThread   `json:"comments,omitempty"`
+	Resolved    *bool             `json:"resolved,omitempty"`
+	Submitted   bool              `json:"submitted"`
+}
+
+// Review represents the entire state of a code review.
+//
+// This extends Summary to also include a list of reports for both the
+// continuous integration status, and the static analysis runs. Those reports
+// correspond to either the current commit in the review ref (for pending
+// reviews), or to the last commented-upon commit (for submitted reviews).
+type Review struct {
+	*Summary
+	Reports  []ci.Report       `json:"reports,omitempty"`
+	Analyses []analyses.Report `json:"analyses,omitempty"`
+}
+
+type commentsByTimestamp []*comment.Comment
+
+// Interface methods for sorting comment threads by timestamp
+func (cs commentsByTimestamp) Len() int      { return len(cs) }
+func (cs commentsByTimestamp) Swap(i, j int) { cs[i], cs[j] = cs[j], cs[i] }
+func (cs commentsByTimestamp) Less(i, j int) bool {
+	return cs[i].Timestamp < cs[j].Timestamp
+}
+
+type byTimestamp []CommentThread
+
+// Interface methods for sorting comment threads by timestamp
+func (threads byTimestamp) Len() int      { return len(threads) }
+func (threads byTimestamp) Swap(i, j int) { threads[i], threads[j] = threads[j], threads[i] }
+func (threads byTimestamp) Less(i, j int) bool {
+	return threads[i].Comment.Timestamp < threads[j].Comment.Timestamp
+}
+
+type requestsByTimestamp []request.Request
+
+// Interface methods for sorting review requests by timestamp
+func (requests requestsByTimestamp) Len() int { return len(requests) }
+func (requests requestsByTimestamp) Swap(i, j int) {
+	requests[i], requests[j] = requests[j], requests[i]
+}
+func (requests requestsByTimestamp) Less(i, j int) bool {
+	return requests[i].Timestamp < requests[j].Timestamp
+}
+
+type summariesWithNewestRequestsFirst []Summary
+
+// Interface methods for sorting review summaries in reverse chronological order
+func (summaries summariesWithNewestRequestsFirst) Len() int { return len(summaries) }
+func (summaries summariesWithNewestRequestsFirst) Swap(i, j int) {
+	summaries[i], summaries[j] = summaries[j], summaries[i]
+}
+func (summaries summariesWithNewestRequestsFirst) Less(i, j int) bool {
+	return summaries[i].Request.Timestamp > summaries[j].Request.Timestamp
+}
+
+// updateThreadsStatus calculates the aggregate status of a sequence of comment threads.
+//
+// The aggregate status is the conjunction of all of the non-nil child statuses.
+//
+// This has the side-effect of setting the "Resolved" field of all descendant comment threads.
+func updateThreadsStatus(threads []CommentThread) *bool {
+	sort.Stable(byTimestamp(threads))
+	noUnresolved := true
+	var result *bool
+	for i := range threads {
+		thread := &threads[i]
+		thread.updateResolvedStatus()
+		if thread.Resolved != nil {
+			noUnresolved = noUnresolved && *thread.Resolved
+			result = &noUnresolved
+		}
+	}
+	return result
+}
+
+// updateResolvedStatus calculates the aggregate status of a single comment thread,
+// and updates the "Resolved" field of that thread accordingly.
+func (thread *CommentThread) updateResolvedStatus() {
+	resolved := updateThreadsStatus(thread.Children)
+	if resolved == nil {
+		thread.Resolved = thread.Comment.Resolved
+		return
+	}
+
+	if !*resolved {
+		thread.Resolved = resolved
+		return
+	}
+
+	if thread.Comment.Resolved == nil || !*thread.Comment.Resolved {
+		thread.Resolved = nil
+		return
+	}
+
+	thread.Resolved = resolved
+}
+
+// Verify verifies the signature on a comment.
+func (thread *CommentThread) Verify() error {
+	err := gpg.Verify(&thread.Comment)
+	if err != nil {
+		hash, _ := thread.Comment.Hash()
+		return fmt.Errorf("verification of comment [%s] failed: %s", hash, err)
+	}
+	for _, child := range thread.Children {
+		err = child.Verify()
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// mutableThread is an internal-only data structure used to store partially constructed comment threads.
+type mutableThread struct {
+	Hash     string
+	Comment  comment.Comment
+	Edits    []*comment.Comment
+	Children []*mutableThread
+}
+
+// fixMutableThread is a helper method to finalize a mutableThread struct
+// (partially constructed comment thread) as a CommentThread struct
+// (fully constructed comment thread).
+func fixMutableThread(mutableThread *mutableThread) CommentThread {
+	var children []CommentThread
+	edited := len(mutableThread.Edits) > 0
+	for _, mutableChild := range mutableThread.Children {
+		child := fixMutableThread(mutableChild)
+		if (!edited) && child.Edited {
+			edited = true
+		}
+		children = append(children, child)
+	}
+	comment := &mutableThread.Comment
+	if len(mutableThread.Edits) > 0 {
+		sort.Stable(commentsByTimestamp(mutableThread.Edits))
+		comment = mutableThread.Edits[len(mutableThread.Edits)-1]
+	}
+
+	return CommentThread{
+		Hash:     mutableThread.Hash,
+		Comment:  *comment,
+		Original: &mutableThread.Comment,
+		Edits:    mutableThread.Edits,
+		Children: children,
+		Edited:   edited,
+	}
+}
+
+// This function builds the comment thread tree from the log-based list of comments.
+//
+// Since the comments can be processed in any order, this uses an internal mutable
+// data structure, and then converts it to the proper CommentThread structure at the end.
+func buildCommentThreads(commentsByHash map[string]comment.Comment) []CommentThread {
+	threadsByHash := make(map[string]*mutableThread)
+	for hash, comment := range commentsByHash {
+		thread, ok := threadsByHash[hash]
+		if !ok {
+			thread = &mutableThread{
+				Hash:    hash,
+				Comment: comment,
+			}
+			threadsByHash[hash] = thread
+		}
+	}
+	var rootHashes []string
+	for hash, thread := range threadsByHash {
+		if thread.Comment.Original != "" {
+			original, ok := threadsByHash[thread.Comment.Original]
+			if ok {
+				original.Edits = append(original.Edits, &thread.Comment)
+			}
+		} else if thread.Comment.Parent == "" {
+			rootHashes = append(rootHashes, hash)
+		} else {
+			parent, ok := threadsByHash[thread.Comment.Parent]
+			if ok {
+				parent.Children = append(parent.Children, thread)
+			}
+		}
+	}
+	var threads []CommentThread
+	for _, hash := range rootHashes {
+		threads = append(threads, fixMutableThread(threadsByHash[hash]))
+	}
+	return threads
+}
+
+// loadComments reads in the log-structured sequence of comments for a review,
+// and then builds the corresponding tree-structured comment threads.
+func (r *Summary) loadComments(commentNotes []repository.Note) []CommentThread {
+	commentsByHash := comment.ParseAllValid(commentNotes)
+	return buildCommentThreads(commentsByHash)
+}
+
+func getSummaryFromNotes(repo repository.Repo, revision string, requestNotes, commentNotes []repository.Note) (*Summary, error) {
+	requests := request.ParseAllValid(requestNotes)
+	if requests == nil {
+		return nil, fmt.Errorf("Could not find any review requests for %q", revision)
+	}
+	sort.Stable(requestsByTimestamp(requests))
+	reviewSummary := Summary{
+		Repo:        repo,
+		Revision:    revision,
+		Request:     requests[len(requests)-1],
+		AllRequests: requests,
+	}
+	reviewSummary.Comments = reviewSummary.loadComments(commentNotes)
+	reviewSummary.Resolved = updateThreadsStatus(reviewSummary.Comments)
+	return &reviewSummary, nil
+}
+
+// GetSummary returns the summary of the code review specified by its revision
+// and the references which contain that reviews summary and comments.
+//
+// If no review request exists, the returned review summary is nil.
+func GetSummaryViaRefs(repo repository.Repo, requestRef, commentRef,
+	revision string) (*Summary, error) {
+
+	if err := repo.VerifyCommit(revision); err != nil {
+		return nil, fmt.Errorf("Could not find a commit named %q", revision)
+	}
+	requestNotes := repo.GetNotes(requestRef, revision)
+	commentNotes := repo.GetNotes(commentRef, revision)
+	summary, err := getSummaryFromNotes(repo, revision, requestNotes, commentNotes)
+	if err != nil {
+		return nil, err
+	}
+	currentCommit := revision
+	if summary.Request.Alias != "" {
+		currentCommit = summary.Request.Alias
+	}
+
+	if !summary.IsAbandoned() {
+		submitted, err := repo.IsAncestor(currentCommit, summary.Request.TargetRef)
+		if err != nil {
+			return nil, err
+		}
+		summary.Submitted = submitted
+	}
+	return summary, nil
+}
+
+// GetSummary returns the summary of the specified code review.
+//
+// If no review request exists, the returned review summary is nil.
+func GetSummary(repo repository.Repo, revision string) (*Summary, error) {
+	return GetSummaryViaRefs(repo, request.Ref, comment.Ref, revision)
+}
+
+// Details returns the detailed review for the given summary.
+func (r *Summary) Details() (*Review, error) {
+	review := Review{
+		Summary: r,
+	}
+	currentCommit, err := review.GetHeadCommit()
+	if err == nil {
+		review.Reports = ci.ParseAllValid(review.Repo.GetNotes(ci.Ref, currentCommit))
+		review.Analyses = analyses.ParseAllValid(review.Repo.GetNotes(analyses.Ref, currentCommit))
+	}
+	return &review, nil
+}
+
+// IsAbandoned returns whether or not the given review has been abandoned.
+func (r *Summary) IsAbandoned() bool {
+	return r.Request.TargetRef == ""
+}
+
+// IsOpen returns whether or not the given review is still open (neither submitted nor abandoned).
+func (r *Summary) IsOpen() bool {
+	return !r.Submitted && !r.IsAbandoned()
+}
+
+// Verify returns whether or not a summary's comments are a) signed, and b)
+/// that those signatures are verifiable.
+func (r *Summary) Verify() error {
+	err := gpg.Verify(&r.Request)
+	if err != nil {
+		return fmt.Errorf("couldn't verify request targeting: %q: %s",
+			r.Request.TargetRef, err)
+	}
+	for _, thread := range r.Comments {
+		err := thread.Verify()
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Get returns the specified code review.
+//
+// If no review request exists, the returned review is nil.
+func Get(repo repository.Repo, revision string) (*Review, error) {
+	summary, err := GetSummary(repo, revision)
+	if err != nil {
+		return nil, err
+	}
+	if summary == nil {
+		return nil, nil
+	}
+	return summary.Details()
+}
+
+func getIsSubmittedCheck(repo repository.Repo) func(ref, commit string) bool {
+	refCommitsMap := make(map[string]map[string]bool)
+
+	getRefCommitsMap := func(ref string) map[string]bool {
+		commitsMap, ok := refCommitsMap[ref]
+		if ok {
+			return commitsMap
+		}
+		commitsMap = make(map[string]bool)
+		for _, commit := range repo.ListCommits(ref) {
+			commitsMap[commit] = true
+		}
+		refCommitsMap[ref] = commitsMap
+		return commitsMap
+	}
+
+	return func(ref, commit string) bool {
+		return getRefCommitsMap(ref)[commit]
+	}
+}
+
+func unsortedListAll(repo repository.Repo) []Summary {
+	reviewNotesMap, err := repo.GetAllNotes(request.Ref)
+	if err != nil {
+		return nil
+	}
+	discussNotesMap, err := repo.GetAllNotes(comment.Ref)
+	if err != nil {
+		return nil
+	}
+
+	isSubmittedCheck := getIsSubmittedCheck(repo)
+	var reviews []Summary
+	for commit, notes := range reviewNotesMap {
+		summary, err := getSummaryFromNotes(repo, commit, notes, discussNotesMap[commit])
+		if err != nil {
+			continue
+		}
+		if !summary.IsAbandoned() {
+			summary.Submitted = isSubmittedCheck(summary.Request.TargetRef, summary.getStartingCommit())
+		}
+		reviews = append(reviews, *summary)
+	}
+	return reviews
+}
+
+// ListAll returns all reviews stored in the git-notes.
+func ListAll(repo repository.Repo) []Summary {
+	reviews := unsortedListAll(repo)
+	sort.Stable(summariesWithNewestRequestsFirst(reviews))
+	return reviews
+}
+
+// ListOpen returns all reviews that are not yet incorporated into their target refs.
+func ListOpen(repo repository.Repo) []Summary {
+	var openReviews []Summary
+	for _, review := range unsortedListAll(repo) {
+		if review.IsOpen() {
+			openReviews = append(openReviews, review)
+		}
+	}
+	sort.Stable(summariesWithNewestRequestsFirst(openReviews))
+	return openReviews
+}
+
+// GetCurrent returns the current, open code review.
+//
+// If there are multiple matching reviews, then an error is returned.
+func GetCurrent(repo repository.Repo) (*Review, error) {
+	reviewRef, err := repo.GetHeadRef()
+	if err != nil {
+		return nil, err
+	}
+	var matchingReviews []Summary
+	for _, review := range ListOpen(repo) {
+		if review.Request.ReviewRef == reviewRef {
+			matchingReviews = append(matchingReviews, review)
+		}
+	}
+	if matchingReviews == nil {
+		return nil, nil
+	}
+	if len(matchingReviews) != 1 {
+		return nil, fmt.Errorf("There are %d open reviews for the ref \"%s\"", len(matchingReviews), reviewRef)
+	}
+	return matchingReviews[0].Details()
+}
+
+// GetBuildStatusMessage returns a string of the current build-and-test status
+// of the review, or "unknown" if the build-and-test status cannot be determined.
+func (r *Review) GetBuildStatusMessage() string {
+	statusMessage := "unknown"
+	ciReport, err := ci.GetLatestCIReport(r.Reports)
+	if err != nil {
+		return fmt.Sprintf("unknown: %s", err)
+	}
+	if ciReport != nil {
+		statusMessage = fmt.Sprintf("%s (%q)", ciReport.Status, ciReport.URL)
+	}
+	return statusMessage
+}
+
+// GetAnalysesNotes returns all of the notes from the most recent static
+// analysis run recorded in the git notes.
+func (r *Review) GetAnalysesNotes() ([]analyses.Note, error) {
+	latestAnalyses, err := analyses.GetLatestAnalysesReport(r.Analyses)
+	if err != nil {
+		return nil, err
+	}
+	if latestAnalyses == nil {
+		return nil, fmt.Errorf("No analyses available")
+	}
+	return latestAnalyses.GetNotes()
+}
+
+// GetAnalysesMessage returns a string summarizing the results of the
+// most recent static analyses.
+func (r *Review) GetAnalysesMessage() string {
+	latestAnalyses, err := analyses.GetLatestAnalysesReport(r.Analyses)
+	if err != nil {
+		return err.Error()
+	}
+	if latestAnalyses == nil {
+		return "No analyses available"
+	}
+	status := latestAnalyses.Status
+	if status != "" && status != analyses.StatusNeedsMoreWork {
+		return status
+	}
+	analysesNotes, err := latestAnalyses.GetNotes()
+	if err != nil {
+		return err.Error()
+	}
+	if analysesNotes == nil {
+		return "passed"
+	}
+	return fmt.Sprintf("%d warnings\n", len(analysesNotes))
+	// TODO(ojarjur): Figure out the best place to display the actual notes
+}
+
+func prettyPrintJSON(jsonBytes []byte) (string, error) {
+	var prettyBytes bytes.Buffer
+	err := json.Indent(&prettyBytes, jsonBytes, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	return prettyBytes.String(), nil
+}
+
+// GetJSON returns the pretty printed JSON for a review summary.
+func (r *Summary) GetJSON() (string, error) {
+	jsonBytes, err := json.Marshal(*r)
+	if err != nil {
+		return "", err
+	}
+	return prettyPrintJSON(jsonBytes)
+}
+
+// GetJSON returns the pretty printed JSON for a review.
+func (r *Review) GetJSON() (string, error) {
+	jsonBytes, err := json.Marshal(*r)
+	if err != nil {
+		return "", err
+	}
+	return prettyPrintJSON(jsonBytes)
+}
+
+// findLastCommit returns the later (newest) commit from the union of the provided commit
+// and all of the commits that are referenced in the given comment threads.
+func (r *Review) findLastCommit(startingCommit, latestCommit string, commentThreads []CommentThread) string {
+	isLater := func(commit string) bool {
+		if err := r.Repo.VerifyCommit(commit); err != nil {
+			return false
+		}
+		if t, e := r.Repo.IsAncestor(latestCommit, commit); e == nil && t {
+			return true
+		}
+		if t, e := r.Repo.IsAncestor(startingCommit, commit); e == nil && !t {
+			return false
+		}
+		if t, e := r.Repo.IsAncestor(commit, latestCommit); e == nil && t {
+			return false
+		}
+		ct, err := r.Repo.GetCommitTime(commit)
+		if err != nil {
+			return false
+		}
+		lt, err := r.Repo.GetCommitTime(latestCommit)
+		if err != nil {
+			return true
+		}
+		return ct > lt
+	}
+	updateLatest := func(commit string) {
+		if commit == "" {
+			return
+		}
+		if isLater(commit) {
+			latestCommit = commit
+		}
+	}
+	for _, commentThread := range commentThreads {
+		comment := commentThread.Comment
+		if comment.Location != nil {
+			updateLatest(comment.Location.Commit)
+		}
+		updateLatest(r.findLastCommit(startingCommit, latestCommit, commentThread.Children))
+	}
+	return latestCommit
+}
+
+func (r *Summary) getStartingCommit() string {
+	if r.Request.Alias != "" {
+		return r.Request.Alias
+	}
+	return r.Revision
+}
+
+// GetHeadCommit returns the latest commit in a review.
+func (r *Review) GetHeadCommit() (string, error) {
+	currentCommit := r.getStartingCommit()
+	if r.Request.ReviewRef == "" {
+		return currentCommit, nil
+	}
+
+	if r.Submitted {
+		// The review has already been submitted.
+		// Go through the list of comments and find the last commented upon commit.
+		return r.findLastCommit(currentCommit, currentCommit, r.Comments), nil
+	}
+
+	// It is possible that the review ref is no longer an ancestor of the starting
+	// commit (e.g. if a rebase left us in a detached head), in which case we have to
+	// find the head commit without using it.
+	useReviewRef, err := r.Repo.IsAncestor(currentCommit, r.Request.ReviewRef)
+	if err != nil {
+		return "", err
+	}
+	if useReviewRef {
+		return r.Repo.ResolveRefCommit(r.Request.ReviewRef)
+	}
+
+	return r.findLastCommit(currentCommit, currentCommit, r.Comments), nil
+}
+
+// GetBaseCommit returns the commit against which a review should be compared.
+func (r *Review) GetBaseCommit() (string, error) {
+	if !r.IsOpen() {
+		if r.Request.BaseCommit != "" {
+			return r.Request.BaseCommit, nil
+		}
+
+		// This means the review has been submitted, but did not specify a base commit.
+		// In this case, we have to treat the last parent commit as the base. This is
+		// usually what we want, since merging a target branch into a feature branch
+		// results in the previous commit to the feature branch being the first parent,
+		// and the latest commit to the target branch being the second parent.
+		return r.Repo.GetLastParent(r.Revision)
+	}
+
+	targetRefHead, err := r.Repo.ResolveRefCommit(r.Request.TargetRef)
+	if err != nil {
+		return "", err
+	}
+	leftHandSide := targetRefHead
+	rightHandSide := r.Revision
+	if r.Request.ReviewRef != "" {
+		if reviewRefHead, err := r.Repo.ResolveRefCommit(r.Request.ReviewRef); err == nil {
+			rightHandSide = reviewRefHead
+		}
+	}
+
+	return r.Repo.MergeBase(leftHandSide, rightHandSide)
+}
+
+// ListCommits lists the commits included in a review.
+func (r *Review) ListCommits() ([]string, error) {
+	baseCommit, err := r.GetBaseCommit()
+	if err != nil {
+		return nil, err
+	}
+	headCommit, err := r.GetHeadCommit()
+	if err != nil {
+		return nil, err
+	}
+	return r.Repo.ListCommitsBetween(baseCommit, headCommit)
+}
+
+// GetDiff returns the diff for a review.
+func (r *Review) GetDiff(diffArgs ...string) (string, error) {
+	var baseCommit, headCommit string
+	baseCommit, err := r.GetBaseCommit()
+	if err == nil {
+		headCommit, err = r.GetHeadCommit()
+	}
+	if err == nil {
+		return r.Repo.Diff(baseCommit, headCommit, diffArgs...)
+	}
+	return "", err
+}
+
+// AddComment adds the given comment to the review.
+func (r *Review) AddComment(c comment.Comment) error {
+	commentNote, err := c.Write()
+	if err != nil {
+		return err
+	}
+
+	r.Repo.AppendNote(comment.Ref, r.Revision, commentNote)
+	return nil
+}
+
+// Rebase performs an interactive rebase of the review onto its target ref.
+//
+// If the 'archivePrevious' argument is true, then the previous head of the
+// review will be added to the 'refs/devtools/archives/reviews' ref prior
+// to being rewritten. That ensures the review history is kept from being
+// garbage collected.
+func (r *Review) Rebase(archivePrevious bool) error {
+	if archivePrevious {
+		orig, err := r.GetHeadCommit()
+		if err != nil {
+			return err
+		}
+		if err := r.Repo.ArchiveRef(orig, archiveRef); err != nil {
+			return err
+		}
+	}
+	if err := r.Repo.SwitchToRef(r.Request.ReviewRef); err != nil {
+		return err
+	}
+
+	err := r.Repo.RebaseRef(r.Request.TargetRef)
+	if err != nil {
+		return err
+	}
+
+	alias, err := r.Repo.GetCommitHash("HEAD")
+	if err != nil {
+		return err
+	}
+	r.Request.Alias = alias
+	newNote, err := r.Request.Write()
+	if err != nil {
+		return err
+	}
+	return r.Repo.AppendNote(request.Ref, r.Revision, newNote)
+}
+
+// RebaseAndSign performs an interactive rebase of the review onto its
+// target ref. It signs the result of the rebase as well as (re)signs
+// the review request itself.
+//
+// If the 'archivePrevious' argument is true, then the previous head of the
+// review will be added to the 'refs/devtools/archives/reviews' ref prior
+// to being rewritten. That ensures the review history is kept from being
+// garbage collected.
+func (r *Review) RebaseAndSign(archivePrevious bool) error {
+	if archivePrevious {
+		orig, err := r.GetHeadCommit()
+		if err != nil {
+			return err
+		}
+		if err := r.Repo.ArchiveRef(orig, archiveRef); err != nil {
+			return err
+		}
+	}
+	if err := r.Repo.SwitchToRef(r.Request.ReviewRef); err != nil {
+		return err
+	}
+
+	err := r.Repo.RebaseAndSignRef(r.Request.TargetRef)
+	if err != nil {
+		return err
+	}
+
+	alias, err := r.Repo.GetCommitHash("HEAD")
+	if err != nil {
+		return err
+	}
+	r.Request.Alias = alias
+
+	key, err := r.Repo.GetUserSigningKey()
+	if err != nil {
+		return err
+	}
+	err = gpg.Sign(key, &r.Request)
+	if err != nil {
+		return err
+	}
+
+	newNote, err := r.Request.Write()
+	if err != nil {
+		return err
+	}
+	return r.Repo.AppendNote(request.Ref, r.Revision, newNote)
+}
diff --git a/third_party/go/git-appraise/review/review_test.go b/third_party/go/git-appraise/review/review_test.go
new file mode 100644
index 0000000000..af699afd9a
--- /dev/null
+++ b/third_party/go/git-appraise/review/review_test.go
@@ -0,0 +1,870 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+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 review
+
+import (
+	"github.com/google/git-appraise/repository"
+	"github.com/google/git-appraise/review/comment"
+	"github.com/google/git-appraise/review/request"
+	"sort"
+	"testing"
+)
+
+func TestCommentSorting(t *testing.T) {
+	sampleComments := []*comment.Comment{
+		&comment.Comment{
+			Timestamp:   "012400",
+			Description: "Fourth",
+		},
+		&comment.Comment{
+			Timestamp:   "012400",
+			Description: "Fifth",
+		},
+		&comment.Comment{
+			Timestamp:   "012346",
+			Description: "Second",
+		},
+		&comment.Comment{
+			Timestamp:   "012345",
+			Description: "First",
+		},
+		&comment.Comment{
+			Timestamp:   "012347",
+			Description: "Third",
+		},
+	}
+	sort.Stable(commentsByTimestamp(sampleComments))
+	descriptions := []string{}
+	for _, comment := range sampleComments {
+		descriptions = append(descriptions, comment.Description)
+	}
+	if !(descriptions[0] == "First" && descriptions[1] == "Second" && descriptions[2] == "Third" && descriptions[3] == "Fourth" && descriptions[4] == "Fifth") {
+		t.Fatalf("Comment ordering failed. Got %v", sampleComments)
+	}
+}
+
+func TestThreadSorting(t *testing.T) {
+	sampleThreads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp:   "012400",
+				Description: "Fourth",
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp:   "012400",
+				Description: "Fifth",
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp:   "012346",
+				Description: "Second",
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp:   "012345",
+				Description: "First",
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp:   "012347",
+				Description: "Third",
+			},
+		},
+	}
+	sort.Stable(byTimestamp(sampleThreads))
+	descriptions := []string{}
+	for _, thread := range sampleThreads {
+		descriptions = append(descriptions, thread.Comment.Description)
+	}
+	if !(descriptions[0] == "First" && descriptions[1] == "Second" && descriptions[2] == "Third" && descriptions[3] == "Fourth" && descriptions[4] == "Fifth") {
+		t.Fatalf("Comment thread ordering failed. Got %v", sampleThreads)
+	}
+}
+
+func TestRequestSorting(t *testing.T) {
+	sampleRequests := []request.Request{
+		request.Request{
+			Timestamp:   "012400",
+			Description: "Fourth",
+		},
+		request.Request{
+			Timestamp:   "012400",
+			Description: "Fifth",
+		},
+		request.Request{
+			Timestamp:   "012346",
+			Description: "Second",
+		},
+		request.Request{
+			Timestamp:   "012345",
+			Description: "First",
+		},
+		request.Request{
+			Timestamp:   "012347",
+			Description: "Third",
+		},
+	}
+	sort.Stable(requestsByTimestamp(sampleRequests))
+	descriptions := []string{}
+	for _, r := range sampleRequests {
+		descriptions = append(descriptions, r.Description)
+	}
+	if !(descriptions[0] == "First" && descriptions[1] == "Second" && descriptions[2] == "Third" && descriptions[3] == "Fourth" && descriptions[4] == "Fifth") {
+		t.Fatalf("Review request ordering failed. Got %v", sampleRequests)
+	}
+}
+
+func validateUnresolved(t *testing.T, resolved *bool) {
+	if resolved != nil {
+		t.Fatalf("Expected resolved status to be unset, but instead it was %v", *resolved)
+	}
+}
+
+func validateAccepted(t *testing.T, resolved *bool) {
+	if resolved == nil {
+		t.Fatal("Expected resolved status to be true, but it was unset")
+	}
+	if !*resolved {
+		t.Fatal("Expected resolved status to be true, but it was false")
+	}
+}
+
+func validateRejected(t *testing.T, resolved *bool) {
+	if resolved == nil {
+		t.Fatal("Expected resolved status to be false, but it was unset")
+	}
+	if *resolved {
+		t.Fatal("Expected resolved status to be false, but it was true")
+	}
+}
+
+func (commentThread *CommentThread) validateUnresolved(t *testing.T) {
+	validateUnresolved(t, commentThread.Resolved)
+}
+
+func (commentThread *CommentThread) validateAccepted(t *testing.T) {
+	validateAccepted(t, commentThread.Resolved)
+}
+
+func (commentThread *CommentThread) validateRejected(t *testing.T) {
+	validateRejected(t, commentThread.Resolved)
+}
+
+func TestSimpleAcceptedThreadStatus(t *testing.T) {
+	resolved := true
+	simpleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &resolved,
+		},
+	}
+	simpleThread.updateResolvedStatus()
+	simpleThread.validateAccepted(t)
+}
+
+func TestSimpleRejectedThreadStatus(t *testing.T) {
+	resolved := false
+	simpleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &resolved,
+		},
+	}
+	simpleThread.updateResolvedStatus()
+	simpleThread.validateRejected(t)
+}
+
+func TestFYIThenAcceptedThreadStatus(t *testing.T) {
+	accepted := true
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: nil,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  &accepted,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateUnresolved(t)
+}
+
+func TestFYIThenFYIThreadStatus(t *testing.T) {
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: nil,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  nil,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateUnresolved(t)
+}
+
+func TestFYIThenRejectedThreadStatus(t *testing.T) {
+	rejected := false
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: nil,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  &rejected,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateRejected(t)
+}
+
+func TestAcceptedThenAcceptedThreadStatus(t *testing.T) {
+	accepted := true
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &accepted,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  &accepted,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateAccepted(t)
+}
+
+func TestAcceptedThenFYIThreadStatus(t *testing.T) {
+	accepted := true
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &accepted,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  nil,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateAccepted(t)
+}
+
+func TestAcceptedThenRejectedThreadStatus(t *testing.T) {
+	accepted := true
+	rejected := false
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &accepted,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  &rejected,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateRejected(t)
+}
+
+func TestRejectedThenAcceptedThreadStatus(t *testing.T) {
+	accepted := true
+	rejected := false
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &rejected,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  &accepted,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateUnresolved(t)
+}
+
+func TestRejectedThenFYIThreadStatus(t *testing.T) {
+	rejected := false
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &rejected,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  nil,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateRejected(t)
+}
+
+func TestRejectedThenRejectedThreadStatus(t *testing.T) {
+	rejected := false
+	sampleThread := CommentThread{
+		Comment: comment.Comment{
+			Resolved: &rejected,
+		},
+		Children: []CommentThread{
+			CommentThread{
+				Comment: comment.Comment{
+					Timestamp: "012345",
+					Resolved:  &rejected,
+				},
+			},
+		},
+	}
+	sampleThread.updateResolvedStatus()
+	sampleThread.validateRejected(t)
+}
+
+func TestRejectedThenAcceptedThreadsStatus(t *testing.T) {
+	accepted := true
+	rejected := false
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  &rejected,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  &accepted,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateRejected(t, status)
+}
+
+func TestRejectedThenFYIThreadsStatus(t *testing.T) {
+	rejected := false
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  &rejected,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  nil,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateRejected(t, status)
+}
+
+func TestRejectedThenRejectedThreadsStatus(t *testing.T) {
+	rejected := false
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  &rejected,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  &rejected,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateRejected(t, status)
+}
+
+func TestAcceptedThenAcceptedThreadsStatus(t *testing.T) {
+	accepted := true
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  &accepted,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  &accepted,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateAccepted(t, status)
+}
+
+func TestAcceptedThenFYIThreadsStatus(t *testing.T) {
+	accepted := true
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  &accepted,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  nil,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateAccepted(t, status)
+}
+
+func TestAcceptedThenRejectedThreadsStatus(t *testing.T) {
+	accepted := true
+	rejected := false
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  &accepted,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  &rejected,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateRejected(t, status)
+}
+
+func TestFYIThenAcceptedThreadsStatus(t *testing.T) {
+	accepted := true
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  nil,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  &accepted,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateAccepted(t, status)
+}
+
+func TestFYIThenFYIThreadsStatus(t *testing.T) {
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  nil,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  nil,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateUnresolved(t, status)
+}
+
+func TestFYIThenRejectedThreadsStatus(t *testing.T) {
+	rejected := false
+	threads := []CommentThread{
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012345",
+				Resolved:  nil,
+			},
+		},
+		CommentThread{
+			Comment: comment.Comment{
+				Timestamp: "012346",
+				Resolved:  &rejected,
+			},
+		},
+	}
+	status := updateThreadsStatus(threads)
+	validateRejected(t, status)
+}
+
+func TestBuildCommentThreads(t *testing.T) {
+	rejected := false
+	accepted := true
+	root := comment.Comment{
+		Timestamp:   "012345",
+		Resolved:    nil,
+		Description: "root",
+	}
+	rootHash, err := root.Hash()
+	if err != nil {
+		t.Fatal(err)
+	}
+	child := comment.Comment{
+		Timestamp:   "012346",
+		Resolved:    nil,
+		Parent:      rootHash,
+		Description: "child",
+	}
+	childHash, err := child.Hash()
+	updatedChild := comment.Comment{
+		Timestamp:   "012346",
+		Resolved:    &rejected,
+		Original:    childHash,
+		Description: "updated child",
+	}
+	updatedChildHash, err := updatedChild.Hash()
+	if err != nil {
+		t.Fatal(err)
+	}
+	leaf := comment.Comment{
+		Timestamp:   "012347",
+		Resolved:    &accepted,
+		Parent:      childHash,
+		Description: "leaf",
+	}
+	leafHash, err := leaf.Hash()
+	if err != nil {
+		t.Fatal(err)
+	}
+	commentsByHash := map[string]comment.Comment{
+		rootHash:         root,
+		childHash:        child,
+		updatedChildHash: updatedChild,
+		leafHash:         leaf,
+	}
+	threads := buildCommentThreads(commentsByHash)
+	if len(threads) != 1 {
+		t.Fatalf("Unexpected threads: %v", threads)
+	}
+	rootThread := threads[0]
+	if rootThread.Comment.Description != "root" {
+		t.Fatalf("Unexpected root thread: %v", rootThread)
+	}
+	if !rootThread.Edited {
+		t.Fatalf("Unexpected root thread edited status: %v", rootThread)
+	}
+	if len(rootThread.Children) != 1 {
+		t.Fatalf("Unexpected root children: %v", rootThread.Children)
+	}
+	rootChild := rootThread.Children[0]
+	if rootChild.Comment.Description != "updated child" {
+		t.Fatalf("Unexpected updated child: %v", rootChild)
+	}
+	if rootChild.Original.Description != "child" {
+		t.Fatalf("Unexpected original child: %v", rootChild)
+	}
+	if len(rootChild.Edits) != 1 {
+		t.Fatalf("Unexpected child history: %v", rootChild.Edits)
+	}
+	if len(rootChild.Children) != 1 {
+		t.Fatalf("Unexpected leaves: %v", rootChild.Children)
+	}
+	threadLeaf := rootChild.Children[0]
+	if threadLeaf.Comment.Description != "leaf" {
+		t.Fatalf("Unexpected leaf: %v", threadLeaf)
+	}
+	if len(threadLeaf.Children) != 0 {
+		t.Fatalf("Unexpected leaf children: %v", threadLeaf.Children)
+	}
+	if threadLeaf.Edited {
+		t.Fatalf("Unexpected leaf edited status: %v", threadLeaf)
+	}
+}
+
+func TestGetHeadCommit(t *testing.T) {
+	repo := repository.NewMockRepoForTest()
+
+	submittedSimpleReview, err := Get(repo, repository.TestCommitB)
+	if err != nil {
+		t.Fatal(err)
+	}
+	submittedSimpleReviewHead, err := submittedSimpleReview.GetHeadCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the head commit for a known review of a simple commit: ", err)
+	}
+	if submittedSimpleReviewHead != repository.TestCommitB {
+		t.Fatal("Unexpected head commit computed for a known review of a simple commit.")
+	}
+
+	submittedModifiedReview, err := Get(repo, repository.TestCommitD)
+	if err != nil {
+		t.Fatal(err)
+	}
+	submittedModifiedReviewHead, err := submittedModifiedReview.GetHeadCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the head commit for a known, multi-commit review: ", err)
+	}
+	if submittedModifiedReviewHead != repository.TestCommitE {
+		t.Fatal("Unexpected head commit for a known, multi-commit review.")
+	}
+
+	pendingReview, err := Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pendingReviewHead, err := pendingReview.GetHeadCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the head commit for a known review of a merge commit: ", err)
+	}
+	if pendingReviewHead != repository.TestCommitI {
+		t.Fatal("Unexpected head commit computed for a pending review.")
+	}
+}
+
+func TestGetBaseCommit(t *testing.T) {
+	repo := repository.NewMockRepoForTest()
+
+	submittedSimpleReview, err := Get(repo, repository.TestCommitB)
+	if err != nil {
+		t.Fatal(err)
+	}
+	submittedSimpleReviewBase, err := submittedSimpleReview.GetBaseCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the base commit for a known review of a simple commit: ", err)
+	}
+	if submittedSimpleReviewBase != repository.TestCommitA {
+		t.Fatal("Unexpected base commit computed for a known review of a simple commit.")
+	}
+
+	submittedMergeReview, err := Get(repo, repository.TestCommitD)
+	if err != nil {
+		t.Fatal(err)
+	}
+	submittedMergeReviewBase, err := submittedMergeReview.GetBaseCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the base commit for a known review of a merge commit: ", err)
+	}
+	if submittedMergeReviewBase != repository.TestCommitC {
+		t.Fatal("Unexpected base commit computed for a known review of a merge commit.")
+	}
+
+	pendingReview, err := Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pendingReviewBase, err := pendingReview.GetBaseCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the base commit for a known review of a merge commit: ", err)
+	}
+	if pendingReviewBase != repository.TestCommitF {
+		t.Fatal("Unexpected base commit computed for a pending review.")
+	}
+
+	abandonRequest := pendingReview.Request
+	abandonRequest.TargetRef = ""
+	abandonNote, err := abandonRequest.Write()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := repo.AppendNote(request.Ref, repository.TestCommitG, abandonNote); err != nil {
+		t.Fatal(err)
+	}
+	abandonedReview, err := Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if abandonedReview.IsOpen() {
+		t.Fatal("Failed to update a review to be abandoned")
+	}
+	abandonedReviewBase, err := abandonedReview.GetBaseCommit()
+	if err != nil {
+		t.Fatal("Unable to compute the base commit for an abandoned review: ", err)
+	}
+	if abandonedReviewBase != repository.TestCommitE {
+		t.Fatal("Unexpected base commit computed for an abandoned review.")
+	}
+}
+
+func TestGetRequests(t *testing.T) {
+	repo := repository.NewMockRepoForTest()
+	pendingReview, err := Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(pendingReview.AllRequests) != 3 || pendingReview.Request.Description != "Final description of G" {
+		t.Fatal("Unexpected requests for a pending review: ", pendingReview.AllRequests, pendingReview.Request)
+	}
+}
+
+func TestRebase(t *testing.T) {
+	repo := repository.NewMockRepoForTest()
+	pendingReview, err := Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Rebase the review and then confirm that it has been updated correctly.
+	if err := pendingReview.Rebase(true); err != nil {
+		t.Fatal(err)
+	}
+	reviewJSON, err := pendingReview.GetJSON()
+	if err != nil {
+		t.Fatal(err)
+	}
+	headRef, err := repo.GetHeadRef()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if headRef != pendingReview.Request.ReviewRef {
+		t.Fatal("Failed to switch to the review ref during a rebase")
+	}
+	isAncestor, err := repo.IsAncestor(pendingReview.Revision, archiveRef)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !isAncestor {
+		t.Fatalf("Commit %q is not archived", pendingReview.Revision)
+	}
+	reviewCommit, err := repo.GetCommitHash(pendingReview.Request.ReviewRef)
+	if err != nil {
+		t.Fatal(err)
+	}
+	reviewAlias := pendingReview.Request.Alias
+	if reviewAlias == "" || reviewAlias == pendingReview.Revision || reviewCommit != reviewAlias {
+		t.Fatalf("Failed to set the review alias: %q", reviewJSON)
+	}
+
+	// Submit the review.
+	if err := repo.SwitchToRef(pendingReview.Request.TargetRef); err != nil {
+		t.Fatal(err)
+	}
+	if err := repo.MergeRef(pendingReview.Request.ReviewRef, true); err != nil {
+		t.Fatal(err)
+	}
+
+	// Reread the review and confirm that it has been submitted.
+	submittedReview, err := Get(repo, pendingReview.Revision)
+	if err != nil {
+		t.Fatal(err)
+	}
+	submittedReviewJSON, err := submittedReview.GetJSON()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !submittedReview.Submitted {
+		t.Fatalf("Failed to submit the review: %q", submittedReviewJSON)
+	}
+}
+
+func TestRebaseDetachedHead(t *testing.T) {
+	repo := repository.NewMockRepoForTest()
+	pendingReview, err := Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Switch the review to having a review ref that is not a branch.
+	pendingReview.Request.ReviewRef = repository.TestAlternateReviewRef
+	newNote, err := pendingReview.Request.Write()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := repo.AppendNote(request.Ref, pendingReview.Revision, newNote); err != nil {
+		t.Fatal(err)
+	}
+	pendingReview, err = Get(repo, repository.TestCommitG)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Rebase the review and then confirm that it has been updated correctly.
+	if err := pendingReview.Rebase(true); err != nil {
+		t.Fatal(err)
+	}
+	headRef, err := repo.GetHeadRef()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if headRef != pendingReview.Request.Alias {
+		t.Fatal("Failed to switch to a detached head during a rebase")
+	}
+	isAncestor, err := repo.IsAncestor(pendingReview.Revision, archiveRef)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !isAncestor {
+		t.Fatalf("Commit %q is not archived", pendingReview.Revision)
+	}
+
+	// Submit the review.
+	if err := repo.SwitchToRef(pendingReview.Request.TargetRef); err != nil {
+		t.Fatal(err)
+	}
+	reviewHead, err := pendingReview.GetHeadCommit()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := repo.MergeRef(reviewHead, true); err != nil {
+		t.Fatal(err)
+	}
+
+	// Reread the review and confirm that it has been submitted.
+	submittedReview, err := Get(repo, pendingReview.Revision)
+	if err != nil {
+		t.Fatal(err)
+	}
+	submittedReviewJSON, err := submittedReview.GetJSON()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !submittedReview.Submitted {
+		t.Fatalf("Failed to submit the review: %q", submittedReviewJSON)
+	}
+}