feat(third_party): Check in git-appraise

This commit is contained in:
Vincent Ambo 2019-07-02 14:19:12 +01:00
parent e03f063052
commit fe642c30f0
38 changed files with 7300 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}
}