diff options
Diffstat (limited to 'third_party/go/git-appraise/review')
-rw-r--r-- | third_party/go/git-appraise/review/analyses/analyses.go | 160 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/analyses/analyses_test.go | 77 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/ci/ci.go | 95 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/ci/ci_test.go | 85 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/comment/comment.go | 266 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/gpg/signable.go | 129 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/request/request.go | 104 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/review.go | 772 | ||||
-rw-r--r-- | third_party/go/git-appraise/review/review_test.go | 870 |
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 000000000000..4828f3b230c2 --- /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 000000000000..00a811ef6a40 --- /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 000000000000..b2cfd22743c2 --- /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 000000000000..c141f053d94d --- /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 000000000000..b1dea49c13e4 --- /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 000000000000..776764c6fc10 --- /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 000000000000..c23fd427a8ee --- /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 000000000000..a23dd17bf798 --- /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 000000000000..af699afd9aeb --- /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) + } +} |