diff options
Diffstat (limited to 'third_party/go/git-appraise/review/review.go')
-rw-r--r-- | third_party/go/git-appraise/review/review.go | 772 |
1 files changed, 772 insertions, 0 deletions
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) +} |