diff options
Diffstat (limited to 'third_party/go/git-appraise')
38 files changed, 7300 insertions, 0 deletions
diff --git a/third_party/go/git-appraise/.gitignore b/third_party/go/git-appraise/.gitignore new file mode 100644 index 000000000000..385b6eee948f --- /dev/null +++ b/third_party/go/git-appraise/.gitignore @@ -0,0 +1,2 @@ +*~ +bin/ diff --git a/third_party/go/git-appraise/CONTRIBUTING.md b/third_party/go/git-appraise/CONTRIBUTING.md new file mode 100644 index 000000000000..8532a3336e18 --- /dev/null +++ b/third_party/go/git-appraise/CONTRIBUTING.md @@ -0,0 +1,24 @@ +Want to contribute? Great! First, read this page (including the small print at the end). + +### Before you contribute +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement](https://developers.google.com/open-source/cla/individual?csw=1) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase. Therefore, we need your permission to use and distribute your code. +We also need to be sure of various other things—for instance that you'll tell +us if you know that your code infringes on other people's patents. You don't +have to sign the CLA until after you've submitted your code for review and a +member has approved it, but you must do it before we can put your code into our +codebase. Before you start working on a larger contribution, you should get in +touch with us first through the issue tracker with your idea so that we can +help out and possibly guide you. Coordinating up front avoids frustrations later. + +### Code reviews +All submissions, including submissions by project members, require review. You +may use a Github pull request to start such a review, but the review itself +will be conducted using this tool. + +### The small print +Contributions made by corporations are covered by a different agreement than +the one above, the Software Grant and Corporate Contributor License Agreement. \ No newline at end of file diff --git a/third_party/go/git-appraise/LICENSE b/third_party/go/git-appraise/LICENSE new file mode 100644 index 000000000000..d64569567334 --- /dev/null +++ b/third_party/go/git-appraise/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/third_party/go/git-appraise/README.md b/third_party/go/git-appraise/README.md new file mode 100644 index 000000000000..0227c41366e0 --- /dev/null +++ b/third_party/go/git-appraise/README.md @@ -0,0 +1,158 @@ +# Distributed Code Review For Git +[![Build Status](https://travis-ci.org/google/git-appraise.svg?branch=master)](https://travis-ci.org/google/git-appraise) + +This repo contains a command line tool for performing code reviews on git +repositories. + +## Overview + +This tool is a *distributed* code review system for git repos. + +By "distributed", we mean that code reviews are stored inside of the repository +as git objects. Every developer on your team has their own copy of the review +history that they can push or pull. When pulling, updates from the remote +repo are automatically merged by the tool. + +This design removes the need for any sort of server-side setup. As a result, +this tool can work with any git hosting provider, and the only setup required +is installing the client on your workstation. + +## Installation + +Assuming you have the [Go tools installed](https://golang.org/doc/install), run +the following command: + + go get github.com/google/git-appraise/git-appraise + +Then, either make sure that `${GOPATH}/bin` is in your PATH, or explicitly add the +"appraise" git alias by running the following command. + + git config --global alias.appraise '!'"${GOPATH}/bin/git-appraise" + +#### Windows: + + git config --global alias.appraise "!%GOPATH%/bin/git-appraise.exe" + +## Requirements + +This tool expects to run in an environment with the following attributes: + +1. The git command line tool is installed, and included in the PATH. +2. The tool is run from within a git repo. +3. The git command line tool is configured with the credentials it needs to + push to and pull from the remote repos. + +## Usage + +Requesting a code review: + + git appraise request + +Pushing code reviews to a remote: + + git appraise push [<remote>] + +Pulling code reviews from a remote: + + git appraise pull [<remote>] + +Listing open code reviews: + + git appraise list + +Showing the status of the current review, including comments: + + git appraise show + +Showing the diff of a review: + + git appraise show --diff [--diff-opts "<diff-options>"] [<review-hash>] + +Commenting on a review: + + git appraise comment -m "<message>" [-f <file> [-l <line>]] [<review-hash>] + +Accepting the changes in a review: + + git appraise accept [-m "<message>"] [<review-hash>] + +Submitting the current review: + + git appraise submit [--merge | --rebase] + +A more detailed getting started doc is available [here](docs/tutorial.md). + +## Metadata + +The code review data is stored in [git-notes](https://git-scm.com/docs/git-notes), +using the formats described below. Each item stored is written as a single +line of JSON, and is written with at most one such item per line. This allows +the git notes to be automatically merged using the "cat\_sort\_uniq" strategy. + +Since these notes are not in a human-friendly form, all of the refs used to +track them start with the prefix "refs/notes/devtools". This helps make it +clear that these are meant to be read and written by automated tools. + +When a field named "v" appears in one of these notes, it is used to denote +the version of the metadata format being used. If that field is missing, then +it defaults to the value 0, which corresponds to this initial version of the +formats. + +### Code Review Requests + +Code review requests are stored in the "refs/notes/devtools/reviews" ref, and +annotate the first revision in a review. They must conform to the +[request schema](schema/request.json). + +If there are multiple requests for a single commit, then they are sorted by +timestamp and the final request is treated as the current one. This sorting +should be done in a stable manner, so that if there are multiple requests +with the same timestamp, then the last such request in the note is treated +as the current one. + +This design allows a user to update a review request by re-running the +`git appraise request` command. + +### Continuous Integration Status + +Continuous integration build and test results are stored in the +"refs/notes/devtools/ci" ref, and annotate the revision that was built and +tested. They must conform to the [ci schema](schema/ci.json). + +### Robot Comments + +Robot comments are comments generated by static analysis tools. These are +stored in the "refs/notes/devtools/analyses" ref, and annotate the revision. +They must conform to the [analysis schema](schema/analysis.json). + +### Review Comments + +Review comments are comments that were written by a person rather than by a +machine. These are stored in the "refs/notes/devtools/discuss" ref, and +annotate the first revision in the review. They must conform to the +[comment schema](schema/comment.json). + +## Integrations + +### Libraries + + - [Go (use git-appraise itself)](https://github.com/google/git-appraise/blob/master/review/review.go) + - [Rust](https://github.com/Nemo157/git-appraise-rs) + +### Graphical User Interfaces + + - [Git-Appraise-Web](https://github.com/google/git-appraise-web) + +### Plugins + + - [Eclipse](https://github.com/google/git-appraise-eclipse) + - [Jenkins](https://github.com/jenkinsci/google-git-notes-publisher-plugin) + +### Mirrors to other systems + + - [GitHub Pull Requests](https://github.com/google/git-pull-request-mirror) + - [Phabricator Revisions](https://github.com/google/git-phabricator-mirror) + +## Contributing + +Please see [the CONTRIBUTING file](CONTRIBUTING.md) for information on contributing to Git Appraise. diff --git a/third_party/go/git-appraise/commands/abandon.go b/third_party/go/git-appraise/commands/abandon.go new file mode 100644 index 000000000000..6f408e1663c9 --- /dev/null +++ b/third_party/go/git-appraise/commands/abandon.go @@ -0,0 +1,139 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + + "github.com/google/git-appraise/commands/input" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" + "github.com/google/git-appraise/review/comment" + "github.com/google/git-appraise/review/gpg" + "github.com/google/git-appraise/review/request" +) + +var abandonFlagSet = flag.NewFlagSet("abandon", flag.ExitOnError) + +var ( + abandonMessageFile = abandonFlagSet.String("F", "", "Take the comment from the given file. Use - to read the message from the standard input") + abandonMessage = abandonFlagSet.String("m", "", "Message to attach to the review") + + abandonSign = abandonFlagSet.Bool("S", false, + "Sign the contents of the abandonment") +) + +// abandonReview adds an NMW comment to the current code review. +func abandonReview(repo repository.Repo, args []string) error { + abandonFlagSet.Parse(args) + args = abandonFlagSet.Args() + + var r *review.Review + var err error + if len(args) > 1 { + return errors.New("Only abandon a single review is supported.") + } + + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + + if err != nil { + return fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return errors.New("There is no matching review.") + } + + if *abandonMessageFile != "" && *abandonMessage == "" { + *abandonMessage, err = input.FromFile(*abandonMessageFile) + if err != nil { + return err + } + } + if *abandonMessageFile == "" && *abandonMessage == "" { + *abandonMessage, err = input.LaunchEditor(repo, commentFilename) + if err != nil { + return err + } + } + + abandonedCommit, err := r.GetHeadCommit() + if err != nil { + return err + } + location := comment.Location{ + Commit: abandonedCommit, + } + resolved := false + userEmail, err := repo.GetUserEmail() + if err != nil { + return err + } + c := comment.New(userEmail, *abandonMessage) + c.Location = &location + c.Resolved = &resolved + + var key string + if *abandonSign { + key, err := repo.GetUserSigningKey() + if err != nil { + return err + } + err = gpg.Sign(key, &c) + if err != nil { + return err + } + } + + err = r.AddComment(c) + if err != nil { + return err + } + + // Empty target ref indicates that request was abandoned + r.Request.TargetRef = "" + // (re)sign the request after clearing out `TargetRef'. + if *abandonSign { + err = gpg.Sign(key, &r.Request) + if err != nil { + return err + } + } + + note, err := r.Request.Write() + if err != nil { + return err + } + + return repo.AppendNote(request.Ref, r.Revision, note) +} + +// abandonCmd defines the "abandon" subcommand. +var abandonCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s abandon [<option>...] [<commit>]\n\nOptions:\n", arg0) + abandonFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return abandonReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/accept.go b/third_party/go/git-appraise/commands/accept.go new file mode 100644 index 000000000000..b50f424c252b --- /dev/null +++ b/third_party/go/git-appraise/commands/accept.go @@ -0,0 +1,109 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + "github.com/google/git-appraise/commands/input" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" + "github.com/google/git-appraise/review/comment" + "github.com/google/git-appraise/review/gpg" +) + +var acceptFlagSet = flag.NewFlagSet("accept", flag.ExitOnError) + +var ( + acceptMessageFile = acceptFlagSet.String("F", "", "Take the comment from the given file. Use - to read the message from the standard input") + acceptMessage = acceptFlagSet.String("m", "", "Message to attach to the review") + + acceptSign = acceptFlagSet.Bool("S", false, + "sign the contents of the acceptance") +) + +// acceptReview adds an LGTM comment to the current code review. +func acceptReview(repo repository.Repo, args []string) error { + acceptFlagSet.Parse(args) + args = acceptFlagSet.Args() + + var r *review.Review + var err error + if len(args) > 1 { + return errors.New("Only accepting a single review is supported.") + } + + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + + if err != nil { + return fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return errors.New("There is no matching review.") + } + + acceptedCommit, err := r.GetHeadCommit() + if err != nil { + return err + } + location := comment.Location{ + Commit: acceptedCommit, + } + resolved := true + userEmail, err := repo.GetUserEmail() + if err != nil { + return err + } + + if *acceptMessageFile != "" && *acceptMessage == "" { + *acceptMessage, err = input.FromFile(*acceptMessageFile) + if err != nil { + return err + } + } + + c := comment.New(userEmail, *acceptMessage) + c.Location = &location + c.Resolved = &resolved + if *acceptSign { + key, err := repo.GetUserSigningKey() + if err != nil { + return err + } + err = gpg.Sign(key, &c) + if err != nil { + return err + } + } + return r.AddComment(c) +} + +// acceptCmd defines the "accept" subcommand. +var acceptCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s accept [<option>...] [<commit>]\n\nOptions:\n", arg0) + acceptFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return acceptReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/commands.go b/third_party/go/git-appraise/commands/commands.go new file mode 100644 index 000000000000..75b8c72d3769 --- /dev/null +++ b/third_party/go/git-appraise/commands/commands.go @@ -0,0 +1,55 @@ +/* +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 commands contains the assorted sub commands supported by the git-appraise tool. +package commands + +import ( + "github.com/google/git-appraise/repository" +) + +const notesRefPattern = "refs/notes/devtools/*" +const archiveRefPattern = "refs/devtools/archives/*" +const commentFilename = "APPRAISE_COMMENT_EDITMSG" + +// Command represents the definition of a single command. +type Command struct { + Usage func(string) + RunMethod func(repository.Repo, []string) error +} + +// Run executes a command, given its arguments. +// +// The args parameter is all of the command line args that followed the +// subcommand. +func (cmd *Command) Run(repo repository.Repo, args []string) error { + return cmd.RunMethod(repo, args) +} + +// CommandMap defines all of the available (sub)commands. +var CommandMap = map[string]*Command{ + "abandon": abandonCmd, + "accept": acceptCmd, + "comment": commentCmd, + "list": listCmd, + "pull": pullCmd, + "push": pushCmd, + "rebase": rebaseCmd, + "reject": rejectCmd, + "request": requestCmd, + "show": showCmd, + "submit": submitCmd, +} diff --git a/third_party/go/git-appraise/commands/comment.go b/third_party/go/git-appraise/commands/comment.go new file mode 100644 index 000000000000..554ac6dc78b8 --- /dev/null +++ b/third_party/go/git-appraise/commands/comment.go @@ -0,0 +1,165 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + + "github.com/google/git-appraise/commands/input" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" + "github.com/google/git-appraise/review/comment" + "github.com/google/git-appraise/review/gpg" +) + +var commentFlagSet = flag.NewFlagSet("comment", flag.ExitOnError) +var commentLocation = comment.Range{} + +var ( + commentMessageFile = commentFlagSet.String("F", "", "Take the comment from the given file. Use - to read the message from the standard input") + commentMessage = commentFlagSet.String("m", "", "Message to attach to the review") + commentParent = commentFlagSet.String("p", "", "Parent comment") + commentFile = commentFlagSet.String("f", "", "File being commented upon") + commentLgtm = commentFlagSet.Bool("lgtm", false, "'Looks Good To Me'. Set this to express your approval. This cannot be combined with nmw") + commentNmw = commentFlagSet.Bool("nmw", false, "'Needs More Work'. Set this to express your disapproval. This cannot be combined with lgtm") + commentSign = commentFlagSet.Bool("S", false, + "Sign the contents of the comment") +) + +func init() { + commentFlagSet.Var(&commentLocation, "l", + `File location to be commented upon; requires that the -f flag also be set. +Location follows the following format: + <START LINE>[+<START COLUMN>][:<END LINE>[+<END COLUMN>]] +So, in order to comment starting on the 5th character of the 2nd line until (and +including) the 4th character of the 7th line, use: + -l 2+5:7+4`) +} + +// commentHashExists checks if the given comment hash exists in the given comment threads. +func commentHashExists(hashToFind string, threads []review.CommentThread) bool { + for _, thread := range threads { + if thread.Hash == hashToFind { + return true + } + if commentHashExists(hashToFind, thread.Children) { + return true + } + } + return false +} + +// commentOnReview adds a comment to the current code review. +func commentOnReview(repo repository.Repo, args []string) error { + commentFlagSet.Parse(args) + args = commentFlagSet.Args() + + var r *review.Review + var err error + if len(args) > 1 { + return errors.New("Only accepting a single review is supported.") + } + + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + + if err != nil { + return fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return errors.New("There is no matching review.") + } + + if *commentLgtm && *commentNmw { + return errors.New("You cannot combine the flags -lgtm and -nmw.") + } + if commentLocation != (comment.Range{}) && *commentFile == "" { + return errors.New("Specifying a line number with the -l flag requires that you also specify a file name with the -f flag.") + } + if *commentParent != "" && !commentHashExists(*commentParent, r.Comments) { + return errors.New("There is no matching parent comment.") + } + + if *commentMessageFile != "" && *commentMessage == "" { + *commentMessage, err = input.FromFile(*commentMessageFile) + if err != nil { + return err + } + } + if *commentMessageFile == "" && *commentMessage == "" { + *commentMessage, err = input.LaunchEditor(repo, commentFilename) + if err != nil { + return err + } + } + + commentedUponCommit, err := r.GetHeadCommit() + if err != nil { + return err + } + location := comment.Location{ + Commit: commentedUponCommit, + } + if *commentFile != "" { + location.Path = *commentFile + location.Range = &commentLocation + if err := location.Check(r.Repo); err != nil { + return fmt.Errorf("Unable to comment on the given location: %v", err) + } + } + + userEmail, err := repo.GetUserEmail() + if err != nil { + return err + } + c := comment.New(userEmail, *commentMessage) + c.Location = &location + c.Parent = *commentParent + if *commentLgtm || *commentNmw { + resolved := *commentLgtm + c.Resolved = &resolved + } + + if *commentSign { + key, err := repo.GetUserSigningKey() + if err != nil { + return err + } + err = gpg.Sign(key, &c) + if err != nil { + return err + } + } + + return r.AddComment(c) +} + +// commentCmd defines the "comment" subcommand. +var commentCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s comment [<option>...] [<review-hash>]\n\nOptions:\n", arg0) + commentFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return commentOnReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/input/input.go b/third_party/go/git-appraise/commands/input/input.go new file mode 100644 index 000000000000..9a8678a8272e --- /dev/null +++ b/third_party/go/git-appraise/commands/input/input.go @@ -0,0 +1,118 @@ +/* +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 input + +import ( + "bufio" + "bytes" + "fmt" + "github.com/google/git-appraise/repository" + "io/ioutil" + "os" + "os/exec" +) + +// LaunchEditor launches the default editor configured for the given repo. This +// method blocks until the editor command has returned. +// +// The specified filename should be a temporary file and provided as a relative path +// from the repo (e.g. "FILENAME" will be converted to ".git/FILENAME"). This file +// will be deleted after the editor is closed and its contents have been read. +// +// This method returns the text that was read from the temporary file, or +// an error if any step in the process failed. +func LaunchEditor(repo repository.Repo, fileName string) (string, error) { + editor, err := repo.GetCoreEditor() + if err != nil { + return "", fmt.Errorf("Unable to detect default git editor: %v\n", err) + } + + path := fmt.Sprintf("%s/.git/%s", repo.GetPath(), fileName) + + cmd, err := startInlineCommand(editor, path) + if err != nil { + // Running the editor directly did not work. This might mean that + // the editor string is not a path to an executable, but rather + // a shell command (e.g. "emacsclient --tty"). As such, we'll try + // to run the command through bash, and if that fails, try with sh + args := []string{"-c", fmt.Sprintf("%s %q", editor, path)} + cmd, err = startInlineCommand("bash", args...) + if err != nil { + cmd, err = startInlineCommand("sh", args...) + } + } + if err != nil { + return "", fmt.Errorf("Unable to start editor: %v\n", err) + } + + if err := cmd.Wait(); err != nil { + return "", fmt.Errorf("Editing finished with error: %v\n", err) + } + + output, err := ioutil.ReadFile(path) + if err != nil { + os.Remove(path) + return "", fmt.Errorf("Error reading edited file: %v\n", err) + } + os.Remove(path) + return string(output), err +} + +// FromFile loads and returns the contents of a given file. If - is passed +// through, much like git, it will read from stdin. This can be piped data, +// unless there is a tty in which case the user will be prompted to enter a +// message. +func FromFile(fileName string) (string, error) { + if fileName == "-" { + stat, err := os.Stdin.Stat() + if err != nil { + return "", fmt.Errorf("Error reading from stdin: %v\n", err) + } + if (stat.Mode() & os.ModeCharDevice) == 0 { + // There is no tty. This will allow us to read piped data instead. + output, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("Error reading from stdin: %v\n", err) + } + return string(output), err + } + + fmt.Printf("(reading comment from standard input)\n") + var output bytes.Buffer + s := bufio.NewScanner(os.Stdin) + for s.Scan() { + output.Write(s.Bytes()) + output.WriteRune('\n') + } + return output.String(), nil + } + + output, err := ioutil.ReadFile(fileName) + if err != nil { + return "", fmt.Errorf("Error reading file: %v\n", err) + } + return string(output), err +} + +func startInlineCommand(command string, args ...string) (*exec.Cmd, error) { + cmd := exec.Command(command, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Start() + return cmd, err +} diff --git a/third_party/go/git-appraise/commands/list.go b/third_party/go/git-appraise/commands/list.go new file mode 100644 index 000000000000..cc9338dd7e97 --- /dev/null +++ b/third_party/go/git-appraise/commands/list.go @@ -0,0 +1,74 @@ +/* +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 commands + +import ( + "encoding/json" + "flag" + "fmt" + "github.com/google/git-appraise/commands/output" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" +) + +var listFlagSet = flag.NewFlagSet("list", flag.ExitOnError) + +var ( + listAll = listFlagSet.Bool("a", false, "List all reviews (not just the open ones).") + listJSONOutput = listFlagSet.Bool("json", false, "Format the output as JSON") +) + +// listReviews lists all extant reviews. +// TODO(ojarjur): Add more flags for filtering the output (e.g. filtering by reviewer or status). +func listReviews(repo repository.Repo, args []string) error { + listFlagSet.Parse(args) + var reviews []review.Summary + if *listAll { + reviews = review.ListAll(repo) + if !*listJSONOutput { + fmt.Printf("Loaded %d reviews:\n", len(reviews)) + } + } else { + reviews = review.ListOpen(repo) + if !*listJSONOutput { + fmt.Printf("Loaded %d open reviews:\n", len(reviews)) + } + } + if *listJSONOutput { + b, err := json.MarshalIndent(reviews, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + for _, r := range reviews { + output.PrintSummary(&r) + } + return nil +} + +// listCmd defines the "list" subcommand. +var listCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s list [<option>...]\n\nOptions:\n", arg0) + listFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return listReviews(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/output/output.go b/third_party/go/git-appraise/commands/output/output.go new file mode 100644 index 000000000000..4613cd38576b --- /dev/null +++ b/third_party/go/git-appraise/commands/output/output.go @@ -0,0 +1,216 @@ +/* +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 output contains helper methods for pretty-printing code reviews. +package output + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/git-appraise/review" +) + +const ( + // Template for printing the summary of a code review. + reviewSummaryTemplate = `[%s] %.12s + %s +` + // Template for printing the summary of a code review. + reviewDetailsTemplate = ` %q -> %q + reviewers: %q + requester: %q + build status: %s +` + // Template for printing the location of an inline comment + commentLocationTemplate = `%s%q@%.12s +` + // Template for printing a single comment. + commentTemplate = `comment: %s +author: %s +time: %s +status: %s +%s` + // Template for displaying the summary of the comment threads for a review + commentSummaryTemplate = ` comments (%d threads): +` + // Number of lines of context to print for inline comments + contextLineCount = 5 +) + +// getStatusString returns a human friendly string encapsulating both the review's +// resolved status, and its submitted status. +func getStatusString(r *review.Summary) string { + if r.Resolved == nil && r.Submitted { + return "tbr" + } + if r.Resolved == nil { + return "pending" + } + if *r.Resolved && r.Submitted { + return "submitted" + } + if *r.Resolved { + return "accepted" + } + if r.Submitted { + return "danger" + } + if r.Request.TargetRef == "" { + return "abandon" + } + return "rejected" +} + +// PrintSummary prints a single-line summary of a review. +func PrintSummary(r *review.Summary) { + statusString := getStatusString(r) + indentedDescription := strings.Replace(r.Request.Description, "\n", "\n ", -1) + fmt.Printf(reviewSummaryTemplate, statusString, r.Revision, indentedDescription) +} + +// reformatTimestamp takes a timestamp string of the form "0123456789" and changes it +// to the form "Mon Jan _2 13:04:05 UTC 2006". +// +// Timestamps that are not in the format we expect are left alone. +func reformatTimestamp(timestamp string) string { + parsedTimestamp, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + // The timestamp is an unexpected format, so leave it alone + return timestamp + } + t := time.Unix(parsedTimestamp, 0) + return t.Format(time.UnixDate) +} + +// showThread prints the detailed output for an entire comment thread. +func showThread(r *review.Review, thread review.CommentThread) error { + comment := thread.Comment + indent := " " + if comment.Location != nil && comment.Location.Path != "" && comment.Location.Range != nil && comment.Location.Range.StartLine > 0 { + contents, err := r.Repo.Show(comment.Location.Commit, comment.Location.Path) + if err != nil { + return err + } + lines := strings.Split(contents, "\n") + err = comment.Location.Check(r.Repo) + if err != nil { + return err + } + if comment.Location.Range.StartLine <= uint32(len(lines)) { + firstLine := comment.Location.Range.StartLine + lastLine := comment.Location.Range.EndLine + + if firstLine == 0 { + firstLine = 1 + } + + if lastLine == 0 { + lastLine = firstLine + } + + if lastLine == firstLine { + minLine := int(lastLine) - int(contextLineCount) + if minLine <= 0 { + minLine = 1 + } + firstLine = uint32(minLine) + } + + fmt.Printf(commentLocationTemplate, indent, comment.Location.Path, comment.Location.Commit) + fmt.Println(indent + "|" + strings.Join(lines[firstLine-1:lastLine], "\n"+indent+"|")) + } + } + return showSubThread(r, thread, indent) +} + +// showSubThread prints the given comment (sub)thread, indented by the given prefix string. +func showSubThread(r *review.Review, thread review.CommentThread, indent string) error { + statusString := "fyi" + if thread.Resolved != nil { + if *thread.Resolved { + statusString = "lgtm" + } else { + statusString = "needs work" + } + } + comment := thread.Comment + threadHash := thread.Hash + timestamp := reformatTimestamp(comment.Timestamp) + commentSummary := fmt.Sprintf(indent+commentTemplate, threadHash, comment.Author, timestamp, statusString, comment.Description) + indent = indent + " " + indentedSummary := strings.Replace(commentSummary, "\n", "\n"+indent, -1) + fmt.Println(indentedSummary) + for _, child := range thread.Children { + err := showSubThread(r, child, indent) + if err != nil { + return err + } + } + return nil +} + +// printAnalyses prints the static analysis results for the latest commit in the review. +func printAnalyses(r *review.Review) { + fmt.Println(" analyses: ", r.GetAnalysesMessage()) +} + +// printComments prints all of the comments for the review, with snippets of the preceding source code. +func printComments(r *review.Review) error { + fmt.Printf(commentSummaryTemplate, len(r.Comments)) + for _, thread := range r.Comments { + err := showThread(r, thread) + if err != nil { + return err + } + } + return nil +} + +// PrintDetails prints a multi-line overview of a review, including all comments. +func PrintDetails(r *review.Review) error { + PrintSummary(r.Summary) + fmt.Printf(reviewDetailsTemplate, r.Request.ReviewRef, r.Request.TargetRef, + strings.Join(r.Request.Reviewers, ", "), + r.Request.Requester, r.GetBuildStatusMessage()) + printAnalyses(r) + if err := printComments(r); err != nil { + return err + } + return nil +} + +// PrintJSON pretty prints the given review in JSON format. +func PrintJSON(r *review.Review) error { + json, err := r.GetJSON() + if err != nil { + return err + } + fmt.Println(json) + return nil +} + +// PrintDiff prints the diff of the review. +func PrintDiff(r *review.Review, diffArgs ...string) error { + diff, err := r.GetDiff(diffArgs...) + if err != nil { + return err + } + fmt.Println(diff) + return nil +} diff --git a/third_party/go/git-appraise/commands/pull.go b/third_party/go/git-appraise/commands/pull.go new file mode 100644 index 000000000000..809c20fdbbfe --- /dev/null +++ b/third_party/go/git-appraise/commands/pull.go @@ -0,0 +1,93 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" +) + +var ( + pullFlagSet = flag.NewFlagSet("pull", flag.ExitOnError) + pullVerify = pullFlagSet.Bool("verify-signatures", false, + "verify the signatures of pulled reviews") +) + +// pull updates the local git-notes used for reviews with those from a remote +// repo. +func pull(repo repository.Repo, args []string) error { + pullFlagSet.Parse(args) + pullArgs := pullFlagSet.Args() + + if len(pullArgs) > 1 { + return errors.New( + "Only pulling from one remote at a time is supported.") + } + + remote := "origin" + if len(pullArgs) == 1 { + remote = pullArgs[0] + } + // This is the easy case. We're not checking signatures so just go the + // normal route. + if !*pullVerify { + return repo.PullNotesAndArchive(remote, notesRefPattern, + archiveRefPattern) + } + + // Otherwise, we collect the fetched reviewed revisions (their hashes), get + // their reviews, and then one by one, verify them. If we make it through + // the set, _then_ we merge the remote reference into the local branch. + revisions, err := repo.FetchAndReturnNewReviewHashes(remote, + notesRefPattern, archiveRefPattern) + if err != nil { + return err + } + for _, revision := range revisions { + rvw, err := review.GetSummaryViaRefs(repo, + "refs/notes/"+remote+"/devtools/reviews", + "refs/notes/"+remote+"/devtools/discuss", revision) + if err != nil { + return err + } + err = rvw.Verify() + if err != nil { + return err + } + fmt.Println("verified review:", revision) + } + + err = repo.MergeNotes(remote, notesRefPattern) + if err != nil { + return err + } + return repo.MergeArchives(remote, archiveRefPattern) +} + +var pullCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s pull [<option>] [<remote>]\n\nOptions:\n", arg0) + pullFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return pull(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/push.go b/third_party/go/git-appraise/commands/push.go new file mode 100644 index 000000000000..c75a25eac738 --- /dev/null +++ b/third_party/go/git-appraise/commands/push.go @@ -0,0 +1,49 @@ +/* +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 commands + +import ( + "errors" + "fmt" + "github.com/google/git-appraise/repository" +) + +// push pushes the local git-notes used for reviews to a remote repo. +func push(repo repository.Repo, args []string) error { + if len(args) > 1 { + return errors.New("Only pushing to one remote at a time is supported.") + } + + remote := "origin" + if len(args) == 1 { + remote = args[0] + } + + if err := repo.PushNotesAndArchive(remote, notesRefPattern, archiveRefPattern); err != nil { + return err + } + return nil +} + +var pushCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s push [<remote>]\n", arg0) + }, + RunMethod: func(repo repository.Repo, args []string) error { + return push(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/rebase.go b/third_party/go/git-appraise/commands/rebase.go new file mode 100644 index 000000000000..2c4595a57693 --- /dev/null +++ b/third_party/go/git-appraise/commands/rebase.go @@ -0,0 +1,100 @@ +/* +Copyright 2016 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 commands + +import ( + "errors" + "flag" + "fmt" + + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" +) + +var rebaseFlagSet = flag.NewFlagSet("rebase", flag.ExitOnError) + +var ( + rebaseArchive = rebaseFlagSet.Bool("archive", true, "Prevent the original commit from being garbage collected.") + rebaseSign = rebaseFlagSet.Bool("S", false, + "Sign the contents of the request after the rebase") +) + +// Validate that the user's request to rebase a review makes sense. +// +// This checks both that the request is well formed, and that the +// corresponding review is in a state where rebasing is appropriate. +func validateRebaseRequest(repo repository.Repo, args []string) (*review.Review, error) { + var r *review.Review + var err error + if len(args) > 1 { + return nil, errors.New("Only rebasing a single review is supported.") + } + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + if err != nil { + return nil, fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return nil, errors.New("There is no matching review.") + } + + if r.Submitted { + return nil, errors.New("The review has already been submitted.") + } + + if r.Request.TargetRef == "" { + return nil, errors.New("The review was abandoned.") + } + + target := r.Request.TargetRef + if err := repo.VerifyGitRef(target); err != nil { + return nil, err + } + + return r, nil +} + +// Rebase the current code review. +// +// The "args" parameter contains all of the command line arguments that followed the subcommand. +func rebaseReview(repo repository.Repo, args []string) error { + rebaseFlagSet.Parse(args) + args = rebaseFlagSet.Args() + + r, err := validateRebaseRequest(repo, args) + if err != nil { + return err + } + if *rebaseSign { + return r.RebaseAndSign(*rebaseArchive) + } + return r.Rebase(*rebaseArchive) +} + +// rebaseCmd defines the "rebase" subcommand. +var rebaseCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s rebase [<option>...] [<review-hash>]\n\nOptions:\n", arg0) + rebaseFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return rebaseReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/reject.go b/third_party/go/git-appraise/commands/reject.go new file mode 100644 index 000000000000..e0e45babf8bc --- /dev/null +++ b/third_party/go/git-appraise/commands/reject.go @@ -0,0 +1,119 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + + "github.com/google/git-appraise/commands/input" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" + "github.com/google/git-appraise/review/comment" + "github.com/google/git-appraise/review/gpg" +) + +var rejectFlagSet = flag.NewFlagSet("reject", flag.ExitOnError) + +var ( + rejectMessageFile = rejectFlagSet.String("F", "", "Take the comment from the given file. Use - to read the message from the standard input") + rejectMessage = rejectFlagSet.String("m", "", "Message to attach to the review") + + rejectSign = rejectFlagSet.Bool("S", false, + "Sign the contents of the rejection") +) + +// rejectReview adds an NMW comment to the current code review. +func rejectReview(repo repository.Repo, args []string) error { + rejectFlagSet.Parse(args) + args = rejectFlagSet.Args() + + var r *review.Review + var err error + if len(args) > 1 { + return errors.New("Only rejecting a single review is supported.") + } + + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + + if err != nil { + return fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return errors.New("There is no matching review.") + } + + if r.Request.TargetRef == "" { + return errors.New("The review was abandoned.") + } + + if *rejectMessageFile != "" && *rejectMessage == "" { + *rejectMessage, err = input.FromFile(*rejectMessageFile) + if err != nil { + return err + } + } + if *rejectMessageFile == "" && *rejectMessage == "" { + *rejectMessage, err = input.LaunchEditor(repo, commentFilename) + if err != nil { + return err + } + } + + rejectedCommit, err := r.GetHeadCommit() + if err != nil { + return err + } + location := comment.Location{ + Commit: rejectedCommit, + } + resolved := false + userEmail, err := repo.GetUserEmail() + if err != nil { + return err + } + c := comment.New(userEmail, *rejectMessage) + c.Location = &location + c.Resolved = &resolved + if *rejectSign { + key, err := repo.GetUserSigningKey() + if err != nil { + return err + } + err = gpg.Sign(key, &c) + if err != nil { + return err + } + } + return r.AddComment(c) +} + +// rejectCmd defines the "reject" subcommand. +var rejectCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s reject [<option>...] [<commit>]\n\nOptions:\n", arg0) + rejectFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return rejectReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/request.go b/third_party/go/git-appraise/commands/request.go new file mode 100644 index 000000000000..9a9854c3f8a6 --- /dev/null +++ b/third_party/go/git-appraise/commands/request.go @@ -0,0 +1,182 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + "strings" + + "github.com/google/git-appraise/commands/input" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review/gpg" + "github.com/google/git-appraise/review/request" +) + +// Template for the "request" subcommand's output. +const requestSummaryTemplate = `Review requested: +Commit: %s +Target Ref: %s +Review Ref: %s +Message: "%s" +` + +var requestFlagSet = flag.NewFlagSet("request", flag.ExitOnError) + +var ( + requestMessageFile = requestFlagSet.String("F", "", "Take the comment from the given file. Use - to read the message from the standard input") + requestMessage = requestFlagSet.String("m", "", "Message to attach to the review") + requestReviewers = requestFlagSet.String("r", "", "Comma-separated list of reviewers") + requestSource = requestFlagSet.String("source", "HEAD", "Revision to review") + requestTarget = requestFlagSet.String("target", "refs/heads/master", "Revision against which to review") + requestQuiet = requestFlagSet.Bool("quiet", false, "Suppress review summary output") + requestAllowUncommitted = requestFlagSet.Bool("allow-uncommitted", false, "Allow uncommitted local changes.") + requestSign = requestFlagSet.Bool("S", false, + "GPG sign the content of the request") +) + +// Build the template review request based solely on the parsed flag values. +func buildRequestFromFlags(requester string) (request.Request, error) { + var reviewers []string + if len(*requestReviewers) > 0 { + for _, reviewer := range strings.Split(*requestReviewers, ",") { + reviewers = append(reviewers, strings.TrimSpace(reviewer)) + } + } + if *requestMessageFile != "" && *requestMessage == "" { + var err error + *requestMessage, err = input.FromFile(*requestMessageFile) + if err != nil { + return request.Request{}, err + } + } + + return request.New(requester, reviewers, *requestSource, *requestTarget, *requestMessage), nil +} + +// Get the commit at which the review request should be anchored. +func getReviewCommit(repo repository.Repo, r request.Request, args []string) (string, string, error) { + if len(args) > 1 { + return "", "", errors.New("Only updating a single review is supported.") + } + if len(args) == 1 { + base, err := repo.MergeBase(r.TargetRef, args[0]) + if err != nil { + return "", "", err + } + return args[0], base, nil + } + + base, err := repo.MergeBase(r.TargetRef, r.ReviewRef) + if err != nil { + return "", "", err + } + reviewCommits, err := repo.ListCommitsBetween(base, r.ReviewRef) + if err != nil { + return "", "", err + } + if reviewCommits == nil { + return "", "", errors.New("There are no commits included in the review request") + } + return reviewCommits[0], base, nil +} + +// Create a new code review request. +// +// The "args" parameter is all of the command line arguments that followed the subcommand. +func requestReview(repo repository.Repo, args []string) error { + requestFlagSet.Parse(args) + args = requestFlagSet.Args() + + if !*requestAllowUncommitted { + // Requesting a code review with uncommited local changes is usually a mistake, so + // we want to report that to the user instead of creating the request. + hasUncommitted, err := repo.HasUncommittedChanges() + if err != nil { + return err + } + if hasUncommitted { + return errors.New("You have uncommitted or untracked files. Use --allow-uncommitted to ignore those.") + } + } + + userEmail, err := repo.GetUserEmail() + if err != nil { + return err + } + r, err := buildRequestFromFlags(userEmail) + if err != nil { + return err + } + if r.ReviewRef == "HEAD" { + headRef, err := repo.GetHeadRef() + if err != nil { + return err + } + r.ReviewRef = headRef + } + if err := repo.VerifyGitRef(r.TargetRef); err != nil { + return err + } + if err := repo.VerifyGitRef(r.ReviewRef); err != nil { + return err + } + + reviewCommit, baseCommit, err := getReviewCommit(repo, r, args) + if err != nil { + return err + } + r.BaseCommit = baseCommit + if r.Description == "" { + description, err := repo.GetCommitMessage(reviewCommit) + if err != nil { + return err + } + r.Description = description + } + if *requestSign { + key, err := repo.GetUserSigningKey() + if err != nil { + return err + } + err = gpg.Sign(key, &r) + if err != nil { + return err + } + } + note, err := r.Write() + if err != nil { + return err + } + repo.AppendNote(request.Ref, reviewCommit, note) + if !*requestQuiet { + fmt.Printf(requestSummaryTemplate, reviewCommit, r.TargetRef, r.ReviewRef, r.Description) + } + return nil +} + +// requestCmd defines the "request" subcommand. +var requestCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s request [<option>...] [<review-hash>]\n\nOptions:\n", arg0) + requestFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return requestReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/request_test.go b/third_party/go/git-appraise/commands/request_test.go new file mode 100644 index 000000000000..3e09892e5760 --- /dev/null +++ b/third_party/go/git-appraise/commands/request_test.go @@ -0,0 +1,36 @@ +/* +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 commands + +import ( + "testing" +) + +func TestBuildRequestFromFlags(t *testing.T) { + args := []string{"-m", "Request message", "-r", "Me, Myself, \nAnd I "} + requestFlagSet.Parse(args) + r, err := buildRequestFromFlags("user@hostname.com") + if err != nil { + t.Fatal(err) + } + if r.Description != "Request message" { + t.Fatalf("Unexpected request description: '%s'", r.Description) + } + if r.Reviewers == nil || len(r.Reviewers) != 3 || r.Reviewers[0] != "Me" || r.Reviewers[1] != "Myself" || r.Reviewers[2] != "And I" { + t.Fatalf("Unexpected reviewers list: '%v'", r.Reviewers) + } +} diff --git a/third_party/go/git-appraise/commands/show.go b/third_party/go/git-appraise/commands/show.go new file mode 100644 index 000000000000..9eb57dd093c7 --- /dev/null +++ b/third_party/go/git-appraise/commands/show.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 commands + +import ( + "errors" + "flag" + "fmt" + "github.com/google/git-appraise/commands/output" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" + "strings" +) + +var showFlagSet = flag.NewFlagSet("show", flag.ExitOnError) + +var ( + showJSONOutput = showFlagSet.Bool("json", false, "Format the output as JSON") + showDiffOutput = showFlagSet.Bool("diff", false, "Show the current diff for the review") + showDiffOptions = showFlagSet.String("diff-opts", "", "Options to pass to the diff tool; can only be used with the --diff option") +) + +// showReview prints the current code review. +func showReview(repo repository.Repo, args []string) error { + showFlagSet.Parse(args) + args = showFlagSet.Args() + if *showDiffOptions != "" && !*showDiffOutput { + return errors.New("The --diff-opts flag can only be used if the --diff flag is set.") + } + + var r *review.Review + var err error + if len(args) > 1 { + return errors.New("Only showing a single review is supported.") + } + + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + + if err != nil { + return fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return errors.New("There is no matching review.") + } + if *showJSONOutput { + return output.PrintJSON(r) + } + if *showDiffOutput { + var diffArgs []string + if *showDiffOptions != "" { + diffArgs = strings.Split(*showDiffOptions, ",") + } + return output.PrintDiff(r, diffArgs...) + } + return output.PrintDetails(r) +} + +// showCmd defines the "show" subcommand. +var showCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s show [<option>...] [<commit>]\n\nOptions:\n", arg0) + showFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return showReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/commands/submit.go b/third_party/go/git-appraise/commands/submit.go new file mode 100644 index 000000000000..58fa00235087 --- /dev/null +++ b/third_party/go/git-appraise/commands/submit.go @@ -0,0 +1,157 @@ +/* +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 commands + +import ( + "errors" + "flag" + "fmt" + "github.com/google/git-appraise/repository" + "github.com/google/git-appraise/review" +) + +var submitFlagSet = flag.NewFlagSet("submit", flag.ExitOnError) + +var ( + submitMerge = submitFlagSet.Bool("merge", false, "Create a merge of the source and target refs.") + submitRebase = submitFlagSet.Bool("rebase", false, "Rebase the source ref onto the target ref.") + submitFastForward = submitFlagSet.Bool("fast-forward", false, "Create a merge using the default fast-forward mode.") + submitTBR = submitFlagSet.Bool("tbr", false, "(To be reviewed) Force the submission of a review that has not been accepted.") + submitArchive = submitFlagSet.Bool("archive", true, "Prevent the original commit from being garbage collected; only affects rebased submits.") + + submitSign = submitFlagSet.Bool("S", false, + "Sign the contents of the submission") +) + +// Submit the current code review request. +// +// The "args" parameter contains all of the command line arguments that followed the subcommand. +func submitReview(repo repository.Repo, args []string) error { + submitFlagSet.Parse(args) + args = submitFlagSet.Args() + + if *submitMerge && *submitRebase { + return errors.New("Only one of --merge or --rebase is allowed.") + } + + var r *review.Review + var err error + if len(args) > 1 { + return errors.New("Only accepting a single review is supported.") + } + if len(args) == 1 { + r, err = review.Get(repo, args[0]) + } else { + r, err = review.GetCurrent(repo) + } + + if err != nil { + return fmt.Errorf("Failed to load the review: %v\n", err) + } + if r == nil { + return errors.New("There is no matching review.") + } + + if r.Submitted { + return errors.New("The review has already been submitted.") + } + + if !*submitTBR && (r.Resolved == nil || !*r.Resolved) { + return errors.New("Not submitting as the review has not yet been accepted.") + } + + target := r.Request.TargetRef + if err := repo.VerifyGitRef(target); err != nil { + return err + } + source, err := r.GetHeadCommit() + if err != nil { + return err + } + + isAncestor, err := repo.IsAncestor(target, source) + if err != nil { + return err + } + if !isAncestor { + return errors.New("Refusing to submit a non-fast-forward review. First merge the target ref.") + } + + if !(*submitRebase || *submitMerge || *submitFastForward) { + submitStrategy, err := repo.GetSubmitStrategy() + if err != nil { + return err + } + if submitStrategy == "merge" && !*submitRebase && !*submitFastForward { + *submitMerge = true + } + if submitStrategy == "rebase" && !*submitMerge && !*submitFastForward { + *submitRebase = true + } + if submitStrategy == "fast-forward" && !*submitRebase && !*submitMerge { + *submitFastForward = true + } + } + + if *submitRebase { + var err error + if *submitSign { + err = r.RebaseAndSign(*submitArchive) + } else { + err = r.Rebase(*submitArchive) + } + if err != nil { + return err + } + + source, err = r.GetHeadCommit() + if err != nil { + return err + } + } + + if err := repo.SwitchToRef(target); err != nil { + return err + } + if *submitMerge { + submitMessage := fmt.Sprintf("Submitting review %.12s", r.Revision) + if *submitSign { + return repo.MergeAndSignRef(source, false, submitMessage, + r.Request.Description) + } else { + return repo.MergeRef(source, false, submitMessage, + r.Request.Description) + } + } else { + if *submitSign { + return repo.MergeAndSignRef(source, true) + } else { + return repo.MergeRef(source, true) + } + } +} + +// submitCmd defines the "submit" subcommand. +var submitCmd = &Command{ + Usage: func(arg0 string) { + fmt.Printf("Usage: %s submit [<option>...] [<review-hash>]\n\nOptions:\n", arg0) + submitFlagSet.PrintDefaults() + }, + RunMethod: func(repo repository.Repo, args []string) error { + return submitReview(repo, args) + }, +} diff --git a/third_party/go/git-appraise/docs/tutorial.md b/third_party/go/git-appraise/docs/tutorial.md new file mode 100644 index 000000000000..6f95bb7e2ff5 --- /dev/null +++ b/third_party/go/git-appraise/docs/tutorial.md @@ -0,0 +1,404 @@ +# Getting started with git-appraise + +This file gives an example code-review workflow using git-appraise. It starts +with cloning a repository and goes all the way through to browsing +your submitted commits. + +The git-appraise tool is largely agnostic of what workflow you use, so feel +free to change things to your liking, but this particular workflow should help +you get started. + +## Cloning your repository + +Since you're using a code review tool, we'll assume that you have a URL that +you can push to and pull from in order to collaborate with the rest of your team. + +First we'll create our local clone of the repository: +```shell +git clone ${URL} example-repo +cd example-repo +``` + +If you are starting from an empty repository, then it's a good practice to add a +README file explaining the purpose of the repository: + +```shell +echo '# Example Repository' > README.md +git add README.md +git commit -m 'Added a README file to the repo' +git push +``` + +## Creating our first review + +Generally, reviews in git-appraise are used to decide if the code in one branch +(called the "source") is ready to merge into another branch (called the +"target"). The meaning of each branch and the policies around merging into a +branch vary from team to team, but for this example we'll use a simple practice +called [GitHub Flow](https://guides.github.com/introduction/flow/). + +Specifically, we'll create a new branch for a particular feature, review the +changes to that branch against our master branch, and then delete the feature +branch once we are done. + +### Creating our change + +Create the feature branch: +```shell +git checkout -b ${USER}/getting-started +git push --set-upstream origin ${USER}/getting-started +``` + +... And make some changes to it: +```shell +echo "This is an example repository used for coming up to speed" >> README.md +git commit -a -m "Added an explanation to the README file" +git push +``` + +### Requesting the review + +Up to this point we've only used the regular commands that come with git. Now, +we will use git-appraise to perform a review: + +Request a review: +```shell +git appraise request +``` + +The output of this will be a summary of the newly requested review: +``` +Review requested: +Commit: 1e6eb14c8014593843c5b5f29377585e4ed55304 +Target Ref: refs/heads/master +Review Ref: refs/heads/ojarjur/getting-started +Message: "Added an explanation to the README file" +``` + +Show the details of the current review: +```shell +git appraise show +``` + +``` +[pending] 1e6eb14c8014 + Added an explanation to the README file + "refs/heads/ojarjur/getting-started" -> "refs/heads/master" + reviewers: "" + requester: "ojarjur@google.com" + build status: unknown + analyses: No analyses available + comments (0 threads): +``` + +Show the changes included in the review: +```shell +git appraise show --diff +``` + +``` +diff --git a/README.md b/README.md +index 08fde78..85c4208 100644 +--- a/README.md ++++ b/README.md +@@ -1 +1,2 @@ + # Example Repository ++This is an example repository used for coming up to speed +``` + +### Sending our updates to the remote repository + +Before a teammate can review our change, we have to make it available to them. +This involves pushing both our commits, and our code review data to the remote +repository: +```shell +git push +git appraise pull +git appraise push +``` + +The command `git appraise pull` is used to make sure that our local code review +data includes everything from the remote repo before we try to push our changes +back to it. If you forget to run this command, then the subsequent call to +`git appraise push` might fail with a message that the push was rejected. If +that happens, simply run `git appraise pull` and try again. + +## Reviewing the change + +Your teammates can review your changes using the same tool. + +Fetch the current data from the remote repository: +```shell +git fetch origin +git appraise pull +``` + +List the open reviews: +```shell +git appraise list +``` + +The output of this command will be a list of entries formatted like this: +``` +Loaded 1 open reviews: +[pending] 1e6eb14c8014 + Added an explanation to the README file +``` + +The text within the square brackets is the status of a review, and for open +reviews will be one of "pending", "accepted", or "rejected". The text which +follows the status is the hash of the first commit in the review. This is +used to uniquely identify reviews, and most git-appraise commands will accept +this hash as an argument in order to select the review to handle. + +For instance, we can see the details of a specific review using the "show" +subcommand: +```shell +git appraise show 1e6eb14c8014 +``` + +``` +[pending] 1e6eb14c8014 + Added an explanation to the README file + "refs/heads/ojarjur/getting-started" -> "refs/heads/master" + reviewers: "" + requester: "ojarjur@google.com" + build status: unknown + analyses: No analyses available + comments (0 threads): +``` + +... or, we can see the diff of the changes under review: +```shell +git appraise show --diff 1e6eb14c8014 +``` + +``` +diff --git a/README.md b/README.md +index 08fde78..85c4208 100644 +--- a/README.md ++++ b/README.md +@@ -1 +1,2 @@ + # Example Repository ++This is an example repository used for coming up to speed +``` + +Comments can be added either for the entire review, or on individual lines: +```shell +git appraise comment -f README.md -l 2 -m "Ah, so that's what this is" 1e6eb14c8014 +``` + +These comments then show up in the output of `git appraise show`: +```shell +git appraise show 1e6eb14c8014 +``` + +``` +[pending] 1e6eb14c8014 + Added an explanation to the README file + "refs/heads/ojarjur/getting-started" -> "refs/heads/master" + reviewers: "" + requester: "ojarjur@google.com" + build status: unknown + analyses: No analyses available + comments (1 threads): + "README.md"@1e6eb14c8014 + |# Example Repository + |This is an example repository used for coming up to speed + comment: bd4c11ecafd443c9d1dde6035e89804160cd7487 + author: ojarjur@google.com + time: Fri Dec 18 10:58:54 PST 2015 + status: fyi + Ah, so that's what this is +``` + +Comments initially only exist in your local repository, so to share them +with the rest of your team you have to push your review changes back: + +```shell +git appraise pull +git appraise push +``` + +When the change is ready to be merged, you indicate that by accepting the +review: + +```shell +git appraise accept 1e6eb14c8014 +git appraise pull +git appraise push +``` + +The updated status of the review will be visible in the output of "show": +```shell +git appraise show 1e6eb14c8014 +``` + +``` +[accepted] 1e6eb14c8014 + Added an explanation to the README file + "refs/heads/ojarjur/getting-started" -> "refs/heads/master" + reviewers: "" + requester: "ojarjur@google.com" + build status: unknown + analyses: No analyses available + comments (2 threads): + "README.md"@1e6eb14c8014 + |# Example Repository + |This is an example repository used for coming up to speed + comment: bd4c11ecafd443c9d1dde6035e89804160cd7487 + author: ojarjur@google.com + time: Fri Dec 18 10:58:54 PST 2015 + status: fyi + Ah, so that's what this is + comment: 4034c60e6ed6f24b01e9a581087d1ab86d376b81 + author: ojarjur@google.com + time: Fri Dec 18 11:02:45 PST 2015 + status: fyi +``` + +## Submitting the change + +Once a review has been accepted, you can merge it with the tool: + +```shell +git appraise submit --merge 1e6eb14c8014 +git push +``` + +The submit command will pop up a text editor where you can edit the default +merge message. That message will be used to create a new commit that is a +merge of the previous commit on the master branch, and the history of all +of your changes to the review. You can see what this looks like using +the `git log --graph` command: + +``` +* commit 3a4d1b8cd264b921c858185f2c36aac283b45e49 +|\ Merge: b404fa3 1e6eb14 +| | Author: Omar Jarjur <ojarjur@google.com> +| | Date: Fri Dec 18 11:06:24 2015 -0800 +| | +| | Submitting review 1e6eb14c8014 +| | +| | Added an explanation to the README file +| | +| * commit 1e6eb14c8014593843c5b5f29377585e4ed55304 +|/ Author: Omar Jarjur <ojarjur@google.com> +| Date: Fri Dec 18 10:49:56 2015 -0800 +| +| Added an explanation to the README file +| +* commit b404fa39ae98950d95ab06012191f58507e51d12 + Author: Omar Jarjur <ojarjur@google.com> + Date: Fri Dec 18 10:48:06 2015 -0800 + + Added a README file to the repo +``` + +This is sometimes called a "merge bubble". When the review is simply accepted +as is, these do not add much value. However, reviews often go through several +rounds of changes before they are accepted. By using these merge commits, we +can preserve both the full history of individual reviews, and the high-level +(review-based) history of the repository. + +This can be seen with the history of git-appraise itself. We can see the high +level review history using `git log --first-parent`: + +``` +commit 83c4d770cfde25c943de161c0cac54d714b7de38 +Merge: 9a607b8 931d1b4 +Author: Omar Jarjur <ojarjur@google.com> +Date: Fri Dec 18 09:46:10 2015 -0800 + + Submitting review 8cb887077783 + + Fix a bug where requesting a review would fail with an erroneous message. + + We were figuring out the set of commits to include in a review by + listing the commits between the head of the target ref and the head of + the source ref. However, this only works if the source ref is a + fast-forward of the target ref. + + This commit changes it so that we use the merge-base of the target and + source refs as the starting point instead of the target ref. + +commit 9a607b8529d7483e5b323303c73da05843ff3ca9 +Author: Harry Lawrence <hazbo@gmx.com> +Date: Fri Dec 18 10:24:00 2015 +0000 + + Added links to Eclipse and Jenkins plugins + + As suggested in #11 + +commit 8876cfff2ed848d50cb559c05d44e11b95ca791c +Merge: 00c0e82 1436c83 +Author: Omar Jarjur <ojarjur@google.com> +Date: Thu Dec 17 12:46:32 2015 -0800 + + Submitting review 09aecba64027 + + Force default git editor when omitting -m + For review comments, the absence of the -m flag will now attempt to load the + user's default git editor. + + i.e. git appraise comment c0a643ff39dd + + An initial draft as discussed in #8 + + I'm still not sure whether or not the file that is saved is in the most appropriate place or not. I like the idea of it being relative to the project although it could have gone in `/tmp` I suppose. + +commit 00c0e827e5b86fb9d200f474d4f65f43677cbc6c +Merge: 31209ce 41fde0b +Author: Omar Jarjur <ojarjur@google.com> +Date: Wed Dec 16 17:10:06 2015 -0800 + + Submitting review 2c9bff89f0f8 + + Improve the error messages returned when a git command fails. + + Previously, we were simply cascading the error returned by the instance + of exec.Command. However, that winds up just being something of the form + "exit status 128", with all of the real error message going to the + Stderr field. + + As such, this commit changes the behavior to save the data written to + stderr, and use it to construct a new error to return. + +... +``` + +Here you see a linear view of the reviews that have been submitted, but if we +run the command `git log --oneline --graph`, then we can see that the full +history of each individual review is also available: + +``` +* 83c4d77 Submitting review 8cb887077783 +|\ +| * 931d1b4 Merge branch 'master' into ojarjur/fix-request-bug +| |\ +| |/ +|/| +* | 9a607b8 Added links to Eclipse and Jenkins plugins +| * c7be567 Merge branch 'master' into ojarjur/fix-request-bug +| |\ +| |/ +|/| +* | 8876cff Submitting review 09aecba64027 +|\ \ +| * | 1436c83 Using git var GIT_EDITOR rather than git config +| * | 09aecba Force default git editor when omitting -m +|/ / +| * 8cb8870 Fix a bug where requesting a review would fail with an erroneous message. +|/ +* 00c0e82 Submitting review 2c9bff89f0f8 +... +``` + +## Cleaning up + +Now that our feature branch has been merged into master, we can delete it: + +```shell +git branch -d ${USER}/getting-started +git push origin --delete ${USER}/getting-started +``` diff --git a/third_party/go/git-appraise/git-appraise/git-appraise.go b/third_party/go/git-appraise/git-appraise/git-appraise.go new file mode 100644 index 000000000000..ca5a30cf6da0 --- /dev/null +++ b/third_party/go/git-appraise/git-appraise/git-appraise.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. +*/ + +// Command git-appraise manages code reviews stored as git-notes in the source repo. +// +// To install, run: +// +// $ go get github.com/google/git-appraise/git-appraise +// +// And for usage information, run: +// +// $ git-appraise help +package main + +import ( + "fmt" + "github.com/google/git-appraise/commands" + "github.com/google/git-appraise/repository" + "os" + "sort" + "strings" +) + +const usageMessageTemplate = `Usage: %s <command> + +Where <command> is one of: + %s + +For individual command usage, run: + %s help <command> +` + +func usage() { + command := os.Args[0] + var subcommands []string + for subcommand := range commands.CommandMap { + subcommands = append(subcommands, subcommand) + } + sort.Strings(subcommands) + fmt.Printf(usageMessageTemplate, command, strings.Join(subcommands, "\n "), command) +} + +func help() { + if len(os.Args) < 3 { + usage() + return + } + subcommand, ok := commands.CommandMap[os.Args[2]] + if !ok { + fmt.Printf("Unknown command %q\n", os.Args[2]) + usage() + return + } + subcommand.Usage(os.Args[0]) +} + +func main() { + if len(os.Args) > 1 && os.Args[1] == "help" { + help() + return + } + cwd, err := os.Getwd() + if err != nil { + fmt.Printf("Unable to get the current working directory: %q\n", err) + return + } + repo, err := repository.NewGitRepo(cwd) + if err != nil { + fmt.Printf("%s must be run from within a git repo.\n", os.Args[0]) + return + } + if len(os.Args) < 2 { + subcommand, ok := commands.CommandMap["list"] + if !ok { + fmt.Printf("Unable to list reviews") + return + } + subcommand.Run(repo, []string{}) + return + } + subcommand, ok := commands.CommandMap[os.Args[1]] + if !ok { + fmt.Printf("Unknown command: %q\n", os.Args[1]) + usage() + return + } + if err := subcommand.Run(repo, os.Args[2:]); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } +} diff --git a/third_party/go/git-appraise/repository/git.go b/third_party/go/git-appraise/repository/git.go new file mode 100644 index 000000000000..31d27ea6d2ec --- /dev/null +++ b/third_party/go/git-appraise/repository/git.go @@ -0,0 +1,987 @@ +/* +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 repository contains helper methods for working with the Git repo. +package repository + +import ( + "bufio" + "bytes" + "crypto/sha1" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" +) + +const branchRefPrefix = "refs/heads/" + +// GitRepo represents an instance of a (local) git repository. +type GitRepo struct { + Path string +} + +// Run the given git command with the given I/O reader/writers, returning an error if it fails. +func (repo *GitRepo) runGitCommandWithIO(stdin io.Reader, stdout, stderr io.Writer, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = repo.Path + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +// Run the given git command and return its stdout, or an error if the command fails. +func (repo *GitRepo) runGitCommandRaw(args ...string) (string, string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + err := repo.runGitCommandWithIO(nil, &stdout, &stderr, args...) + return strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String()), err +} + +// Run the given git command and return its stdout, or an error if the command fails. +func (repo *GitRepo) runGitCommand(args ...string) (string, error) { + stdout, stderr, err := repo.runGitCommandRaw(args...) + if err != nil { + if stderr == "" { + stderr = "Error running git command: " + strings.Join(args, " ") + } + err = fmt.Errorf(stderr) + } + return stdout, err +} + +// Run the given git command using the same stdin, stdout, and stderr as the review tool. +func (repo *GitRepo) runGitCommandInline(args ...string) error { + return repo.runGitCommandWithIO(os.Stdin, os.Stdout, os.Stderr, args...) +} + +// NewGitRepo determines if the given working directory is inside of a git repository, +// and returns the corresponding GitRepo instance if it is. +func NewGitRepo(path string) (*GitRepo, error) { + repo := &GitRepo{Path: path} + _, _, err := repo.runGitCommandRaw("rev-parse") + if err == nil { + return repo, nil + } + if _, ok := err.(*exec.ExitError); ok { + return nil, err + } + return nil, err +} + +// GetPath returns the path to the repo. +func (repo *GitRepo) GetPath() string { + return repo.Path +} + +// GetRepoStateHash returns a hash which embodies the entire current state of a repository. +func (repo *GitRepo) GetRepoStateHash() (string, error) { + stateSummary, error := repo.runGitCommand("show-ref") + return fmt.Sprintf("%x", sha1.Sum([]byte(stateSummary))), error +} + +// GetUserEmail returns the email address that the user has used to configure git. +func (repo *GitRepo) GetUserEmail() (string, error) { + return repo.runGitCommand("config", "user.email") +} + +// GetUserSigningKey returns the key id the user has configured for +// sigining git artifacts. +func (repo *GitRepo) GetUserSigningKey() (string, error) { + return repo.runGitCommand("config", "user.signingKey") +} + +// GetCoreEditor returns the name of the editor that the user has used to configure git. +func (repo *GitRepo) GetCoreEditor() (string, error) { + return repo.runGitCommand("var", "GIT_EDITOR") +} + +// GetSubmitStrategy returns the way in which a review is submitted +func (repo *GitRepo) GetSubmitStrategy() (string, error) { + submitStrategy, _ := repo.runGitCommand("config", "appraise.submit") + return submitStrategy, nil +} + +// HasUncommittedChanges returns true if there are local, uncommitted changes. +func (repo *GitRepo) HasUncommittedChanges() (bool, error) { + out, err := repo.runGitCommand("status", "--porcelain") + if err != nil { + return false, err + } + if len(out) > 0 { + return true, nil + } + return false, nil +} + +// VerifyCommit verifies that the supplied hash points to a known commit. +func (repo *GitRepo) VerifyCommit(hash string) error { + out, err := repo.runGitCommand("cat-file", "-t", hash) + if err != nil { + return err + } + objectType := strings.TrimSpace(string(out)) + if objectType != "commit" { + return fmt.Errorf("Hash %q points to a non-commit object of type %q", hash, objectType) + } + return nil +} + +// VerifyGitRef verifies that the supplied ref points to a known commit. +func (repo *GitRepo) VerifyGitRef(ref string) error { + _, err := repo.runGitCommand("show-ref", "--verify", ref) + return err +} + +// GetHeadRef returns the ref that is the current HEAD. +func (repo *GitRepo) GetHeadRef() (string, error) { + return repo.runGitCommand("symbolic-ref", "HEAD") +} + +// GetCommitHash returns the hash of the commit pointed to by the given ref. +func (repo *GitRepo) GetCommitHash(ref string) (string, error) { + return repo.runGitCommand("show", "-s", "--format=%H", ref) +} + +// ResolveRefCommit returns the commit pointed to by the given ref, which may be a remote ref. +// +// This differs from GetCommitHash which only works on exact matches, in that it will try to +// intelligently handle the scenario of a ref not existing locally, but being known to exist +// in a remote repo. +// +// This method should be used when a command may be performed by either the reviewer or the +// reviewee, while GetCommitHash should be used when the encompassing command should only be +// performed by the reviewee. +func (repo *GitRepo) ResolveRefCommit(ref string) (string, error) { + if err := repo.VerifyGitRef(ref); err == nil { + return repo.GetCommitHash(ref) + } + if strings.HasPrefix(ref, "refs/heads/") { + // The ref is a branch. Check if it exists in exactly one remote + pattern := strings.Replace(ref, "refs/heads", "**", 1) + matchingOutput, err := repo.runGitCommand("for-each-ref", "--format=%(refname)", pattern) + if err != nil { + return "", err + } + matchingRefs := strings.Split(matchingOutput, "\n") + if len(matchingRefs) == 1 && matchingRefs[0] != "" { + // There is exactly one match + return repo.GetCommitHash(matchingRefs[0]) + } + return "", fmt.Errorf("Unable to find a git ref matching the pattern %q", pattern) + } + return "", fmt.Errorf("Unknown git ref %q", ref) +} + +// GetCommitMessage returns the message stored in the commit pointed to by the given ref. +func (repo *GitRepo) GetCommitMessage(ref string) (string, error) { + return repo.runGitCommand("show", "-s", "--format=%B", ref) +} + +// GetCommitTime returns the commit time of the commit pointed to by the given ref. +func (repo *GitRepo) GetCommitTime(ref string) (string, error) { + return repo.runGitCommand("show", "-s", "--format=%ct", ref) +} + +// GetLastParent returns the last parent of the given commit (as ordered by git). +func (repo *GitRepo) GetLastParent(ref string) (string, error) { + return repo.runGitCommand("rev-list", "--skip", "1", "-n", "1", ref) +} + +// GetCommitDetails returns the details of a commit's metadata. +func (repo GitRepo) GetCommitDetails(ref string) (*CommitDetails, error) { + var err error + show := func(formatString string) (result string) { + if err != nil { + return "" + } + result, err = repo.runGitCommand("show", "-s", ref, fmt.Sprintf("--format=tformat:%s", formatString)) + return result + } + + jsonFormatString := "{\"tree\":\"%T\", \"time\": \"%at\"}" + detailsJSON := show(jsonFormatString) + if err != nil { + return nil, err + } + var details CommitDetails + err = json.Unmarshal([]byte(detailsJSON), &details) + if err != nil { + return nil, err + } + details.Author = show("%an") + details.AuthorEmail = show("%ae") + details.Summary = show("%s") + parentsString := show("%P") + details.Parents = strings.Split(parentsString, " ") + if err != nil { + return nil, err + } + return &details, nil +} + +// MergeBase determines if the first commit that is an ancestor of the two arguments. +func (repo *GitRepo) MergeBase(a, b string) (string, error) { + return repo.runGitCommand("merge-base", a, b) +} + +// IsAncestor determines if the first argument points to a commit that is an ancestor of the second. +func (repo *GitRepo) IsAncestor(ancestor, descendant string) (bool, error) { + _, _, err := repo.runGitCommandRaw("merge-base", "--is-ancestor", ancestor, descendant) + if err == nil { + return true, nil + } + if _, ok := err.(*exec.ExitError); ok { + return false, nil + } + return false, fmt.Errorf("Error while trying to determine commit ancestry: %v", err) +} + +// Diff computes the diff between two given commits. +func (repo *GitRepo) Diff(left, right string, diffArgs ...string) (string, error) { + args := []string{"diff"} + args = append(args, diffArgs...) + args = append(args, fmt.Sprintf("%s..%s", left, right)) + return repo.runGitCommand(args...) +} + +// Show returns the contents of the given file at the given commit. +func (repo *GitRepo) Show(commit, path string) (string, error) { + return repo.runGitCommand("show", fmt.Sprintf("%s:%s", commit, path)) +} + +// SwitchToRef changes the currently-checked-out ref. +func (repo *GitRepo) SwitchToRef(ref string) error { + // If the ref starts with "refs/heads/", then we have to trim that prefix, + // or else we will wind up in a detached HEAD state. + if strings.HasPrefix(ref, branchRefPrefix) { + ref = ref[len(branchRefPrefix):] + } + _, err := repo.runGitCommand("checkout", ref) + return err +} + +// mergeArchives merges two archive refs. +func (repo *GitRepo) mergeArchives(archive, remoteArchive string) error { + remoteHash, err := repo.GetCommitHash(remoteArchive) + if err != nil { + return err + } + if remoteHash == "" { + // The remote archive does not exist, so we have nothing to do + return nil + } + + archiveHash, err := repo.GetCommitHash(archive) + if err != nil { + return err + } + if archiveHash == "" { + // The local archive does not exist, so we merely need to set it + _, err := repo.runGitCommand("update-ref", archive, remoteHash) + return err + } + + isAncestor, err := repo.IsAncestor(archiveHash, remoteHash) + if err != nil { + return err + } + if isAncestor { + // The archive can simply be fast-forwarded + _, err := repo.runGitCommand("update-ref", archive, remoteHash, archiveHash) + return err + } + + // Create a merge commit of the two archives + refDetails, err := repo.GetCommitDetails(remoteArchive) + if err != nil { + return err + } + newArchiveHash, err := repo.runGitCommand("commit-tree", "-p", remoteHash, "-p", archiveHash, "-m", "Merge local and remote archives", refDetails.Tree) + if err != nil { + return err + } + newArchiveHash = strings.TrimSpace(newArchiveHash) + _, err = repo.runGitCommand("update-ref", archive, newArchiveHash, archiveHash) + return err +} + +// ArchiveRef adds the current commit pointed to by the 'ref' argument +// under the ref specified in the 'archive' argument. +// +// Both the 'ref' and 'archive' arguments are expected to be the fully +// qualified names of git refs (e.g. 'refs/heads/my-change' or +// 'refs/devtools/archives/reviews'). +// +// If the ref pointed to by the 'archive' argument does not exist +// yet, then it will be created. +func (repo *GitRepo) ArchiveRef(ref, archive string) error { + refHash, err := repo.GetCommitHash(ref) + if err != nil { + return err + } + refDetails, err := repo.GetCommitDetails(ref) + if err != nil { + return err + } + + commitTreeArgs := []string{"commit-tree"} + archiveHash, err := repo.GetCommitHash(archive) + if err != nil { + archiveHash = "" + } else { + commitTreeArgs = append(commitTreeArgs, "-p", archiveHash) + } + commitTreeArgs = append(commitTreeArgs, "-p", refHash, "-m", fmt.Sprintf("Archive %s", refHash), refDetails.Tree) + newArchiveHash, err := repo.runGitCommand(commitTreeArgs...) + if err != nil { + return err + } + newArchiveHash = strings.TrimSpace(newArchiveHash) + updateRefArgs := []string{"update-ref", archive, newArchiveHash} + if archiveHash != "" { + updateRefArgs = append(updateRefArgs, archiveHash) + } + _, err = repo.runGitCommand(updateRefArgs...) + return err +} + +// MergeRef merges the given ref into the current one. +// +// The ref argument is the ref to merge, and fastForward indicates that the +// current ref should only move forward, as opposed to creating a bubble merge. +// The messages argument(s) provide text that should be included in the default +// merge commit message (separated by blank lines). +func (repo *GitRepo) MergeRef(ref string, fastForward bool, messages ...string) error { + args := []string{"merge"} + if fastForward { + args = append(args, "--ff", "--ff-only") + } else { + args = append(args, "--no-ff") + } + if len(messages) > 0 { + commitMessage := strings.Join(messages, "\n\n") + args = append(args, "-e", "-m", commitMessage) + } + args = append(args, ref) + return repo.runGitCommandInline(args...) +} + +// MergeAndSignRef merges the given ref into the current one and signs the +// merge. +// +// The ref argument is the ref to merge, and fastForward indicates that the +// current ref should only move forward, as opposed to creating a bubble merge. +// The messages argument(s) provide text that should be included in the default +// merge commit message (separated by blank lines). +func (repo *GitRepo) MergeAndSignRef(ref string, fastForward bool, + messages ...string) error { + + args := []string{"merge"} + if fastForward { + args = append(args, "--ff", "--ff-only", "-S") + } else { + args = append(args, "--no-ff", "-S") + } + if len(messages) > 0 { + commitMessage := strings.Join(messages, "\n\n") + args = append(args, "-e", "-m", commitMessage) + } + args = append(args, ref) + return repo.runGitCommandInline(args...) +} + +// RebaseRef rebases the current ref onto the given one. +func (repo *GitRepo) RebaseRef(ref string) error { + return repo.runGitCommandInline("rebase", "-i", ref) +} + +// RebaseAndSignRef rebases the current ref onto the given one and signs the +// result. +func (repo *GitRepo) RebaseAndSignRef(ref string) error { + return repo.runGitCommandInline("rebase", "-S", "-i", ref) +} + +// ListCommits returns the list of commits reachable from the given ref. +// +// The generated list is in chronological order (with the oldest commit first). +// +// If the specified ref does not exist, then this method returns an empty result. +func (repo *GitRepo) ListCommits(ref string) []string { + var stdout bytes.Buffer + var stderr bytes.Buffer + if err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "rev-list", "--reverse", ref); err != nil { + return nil + } + + byteLines := bytes.Split(stdout.Bytes(), []byte("\n")) + var commits []string + for _, byteLine := range byteLines { + commits = append(commits, string(byteLine)) + } + return commits +} + +// ListCommitsBetween returns the list of commits between the two given revisions. +// +// The "from" parameter is the starting point (exclusive), and the "to" +// parameter is the ending point (inclusive). +// +// The "from" commit does not need to be an ancestor of the "to" commit. If it +// is not, then the merge base of the two is used as the starting point. +// Admittedly, this makes calling these the "between" commits is a bit of a +// misnomer, but it also makes the method easier to use when you want to +// generate the list of changes in a feature branch, as it eliminates the need +// to explicitly calculate the merge base. This also makes the semantics of the +// method compatible with git's built-in "rev-list" command. +// +// The generated list is in chronological order (with the oldest commit first). +func (repo *GitRepo) ListCommitsBetween(from, to string) ([]string, error) { + out, err := repo.runGitCommand("rev-list", "--reverse", from+".."+to) + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// GetNotes uses the "git" command-line tool to read the notes from the given ref for a given revision. +func (repo *GitRepo) GetNotes(notesRef, revision string) []Note { + var notes []Note + rawNotes, err := repo.runGitCommand("notes", "--ref", notesRef, "show", revision) + if err != nil { + // We just assume that this means there are no notes + return nil + } + for _, line := range strings.Split(rawNotes, "\n") { + notes = append(notes, Note([]byte(line))) + } + return notes +} + +func stringsReader(s []*string) io.Reader { + var subReaders []io.Reader + for _, strPtr := range s { + subReader := strings.NewReader(*strPtr) + subReaders = append(subReaders, subReader, strings.NewReader("\n")) + } + return io.MultiReader(subReaders...) +} + +// splitBatchCheckOutput parses the output of a 'git cat-file --batch-check=...' command. +// +// The output is expected to be formatted as a series of entries, with each +// entry consisting of: +// 1. The SHA1 hash of the git object being output, followed by a space. +// 2. The git "type" of the object (commit, blob, tree, missing, etc), followed by a newline. +// +// To generate this format, make sure that the 'git cat-file' command includes +// the argument '--batch-check=%(objectname) %(objecttype)'. +// +// The return value is a map from object hash to a boolean indicating if that object is a commit. +func splitBatchCheckOutput(out *bytes.Buffer) (map[string]bool, error) { + isCommit := make(map[string]bool) + reader := bufio.NewReader(out) + for { + nameLine, err := reader.ReadString(byte(' ')) + if err == io.EOF { + return isCommit, nil + } + if err != nil { + return nil, fmt.Errorf("Failure while reading the next object name: %v", err) + } + nameLine = strings.TrimSuffix(nameLine, " ") + typeLine, err := reader.ReadString(byte('\n')) + if err != nil && err != io.EOF { + return nil, fmt.Errorf("Failure while reading the next object type: %q - %v", nameLine, err) + } + typeLine = strings.TrimSuffix(typeLine, "\n") + if typeLine == "commit" { + isCommit[nameLine] = true + } + } +} + +// splitBatchCatFileOutput parses the output of a 'git cat-file --batch=...' command. +// +// The output is expected to be formatted as a series of entries, with each +// entry consisting of: +// 1. The SHA1 hash of the git object being output, followed by a newline. +// 2. The size of the object's contents in bytes, followed by a newline. +// 3. The objects contents. +// +// To generate this format, make sure that the 'git cat-file' command includes +// the argument '--batch=%(objectname)\n%(objectsize)'. +func splitBatchCatFileOutput(out *bytes.Buffer) (map[string][]byte, error) { + contentsMap := make(map[string][]byte) + reader := bufio.NewReader(out) + for { + nameLine, err := reader.ReadString(byte('\n')) + if strings.HasSuffix(nameLine, "\n") { + nameLine = strings.TrimSuffix(nameLine, "\n") + } + if err == io.EOF { + return contentsMap, nil + } + if err != nil { + return nil, fmt.Errorf("Failure while reading the next object name: %v", err) + } + sizeLine, err := reader.ReadString(byte('\n')) + if strings.HasSuffix(sizeLine, "\n") { + sizeLine = strings.TrimSuffix(sizeLine, "\n") + } + if err != nil { + return nil, fmt.Errorf("Failure while reading the next object size: %q - %v", nameLine, err) + } + size, err := strconv.Atoi(sizeLine) + if err != nil { + return nil, fmt.Errorf("Failure while parsing the next object size: %q - %v", nameLine, err) + } + contentBytes := make([]byte, size, size) + readDest := contentBytes + len := 0 + err = nil + for err == nil && len < size { + nextLen := 0 + nextLen, err = reader.Read(readDest) + len += nextLen + readDest = contentBytes[len:] + } + contentsMap[nameLine] = contentBytes + if err == io.EOF { + return contentsMap, nil + } + if err != nil { + return nil, err + } + for bs, err := reader.Peek(1); err == nil && bs[0] == byte('\n'); bs, err = reader.Peek(1) { + reader.ReadByte() + } + } +} + +// notesMapping represents the association between a git object and the notes for that object. +type notesMapping struct { + ObjectHash *string + NotesHash *string +} + +// notesOverview represents a high-level overview of all the notes under a single notes ref. +type notesOverview struct { + NotesMappings []*notesMapping + ObjectHashesReader io.Reader + NotesHashesReader io.Reader +} + +// notesOverview returns an overview of the git notes stored under the given ref. +func (repo *GitRepo) notesOverview(notesRef string) (*notesOverview, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "notes", "--ref", notesRef, "list"); err != nil { + return nil, err + } + + var notesMappings []*notesMapping + var objHashes []*string + var notesHashes []*string + outScanner := bufio.NewScanner(&stdout) + for outScanner.Scan() { + line := outScanner.Text() + lineParts := strings.Split(line, " ") + if len(lineParts) != 2 { + return nil, fmt.Errorf("Malformed output line from 'git-notes list': %q", line) + } + objHash := &lineParts[1] + notesHash := &lineParts[0] + notesMappings = append(notesMappings, ¬esMapping{ + ObjectHash: objHash, + NotesHash: notesHash, + }) + objHashes = append(objHashes, objHash) + notesHashes = append(notesHashes, notesHash) + } + err := outScanner.Err() + if err != nil && err != io.EOF { + return nil, fmt.Errorf("Failure parsing the output of 'git-notes list': %v", err) + } + return ¬esOverview{ + NotesMappings: notesMappings, + ObjectHashesReader: stringsReader(objHashes), + NotesHashesReader: stringsReader(notesHashes), + }, nil +} + +// getIsCommitMap returns a mapping of all the annotated objects that are commits. +func (overview *notesOverview) getIsCommitMap(repo *GitRepo) (map[string]bool, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if err := repo.runGitCommandWithIO(overview.ObjectHashesReader, &stdout, &stderr, "cat-file", "--batch-check=%(objectname) %(objecttype)"); err != nil { + return nil, fmt.Errorf("Failure performing a batch file check: %v", err) + } + isCommit, err := splitBatchCheckOutput(&stdout) + if err != nil { + return nil, fmt.Errorf("Failure parsing the output of a batch file check: %v", err) + } + return isCommit, nil +} + +// getNoteContentsMap returns a mapping from all the notes hashes to their contents. +func (overview *notesOverview) getNoteContentsMap(repo *GitRepo) (map[string][]byte, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if err := repo.runGitCommandWithIO(overview.NotesHashesReader, &stdout, &stderr, "cat-file", "--batch=%(objectname)\n%(objectsize)"); err != nil { + return nil, fmt.Errorf("Failure performing a batch file read: %v", err) + } + noteContentsMap, err := splitBatchCatFileOutput(&stdout) + if err != nil { + return nil, fmt.Errorf("Failure parsing the output of a batch file read: %v", err) + } + return noteContentsMap, nil +} + +// GetAllNotes reads the contents of the notes under the given ref for every commit. +// +// The returned value is a mapping from commit hash to the list of notes for that commit. +// +// This is the batch version of the corresponding GetNotes(...) method. +func (repo *GitRepo) GetAllNotes(notesRef string) (map[string][]Note, error) { + // This code is unfortunately quite complicated, but it needs to be so. + // + // Conceptually, this is equivalent to: + // result := make(map[string][]Note) + // for _, commit := range repo.ListNotedRevisions(notesRef) { + // result[commit] = repo.GetNotes(notesRef, commit) + // } + // return result, nil + // + // However, that logic would require separate executions of the 'git' + // command for every annotated commit. For a repo with 10s of thousands + // of reviews, that would mean calling Cmd.Run(...) 10s of thousands of + // times. That, in turn, would take so long that the tool would be unusable. + // + // This method avoids that by taking advantage of the 'git cat-file --batch="..."' + // command. That allows us to use a single invocation of Cmd.Run(...) to + // inspect multiple git objects at once. + // + // As such, regardless of the number of reviews in a repo, we can get all + // of the notes using a total of three invocations of Cmd.Run(...): + // 1. One to list all the annotated objects (and their notes hash) + // 2. A second one to filter out all of the annotated objects that are not commits. + // 3. A final one to get the contents of all of the notes blobs. + overview, err := repo.notesOverview(notesRef) + if err != nil { + return nil, err + } + isCommit, err := overview.getIsCommitMap(repo) + if err != nil { + return nil, fmt.Errorf("Failure building the set of commit objects: %v", err) + } + noteContentsMap, err := overview.getNoteContentsMap(repo) + if err != nil { + return nil, fmt.Errorf("Failure building the mapping from notes hash to contents: %v", err) + } + commitNotesMap := make(map[string][]Note) + for _, notesMapping := range overview.NotesMappings { + if !isCommit[*notesMapping.ObjectHash] { + continue + } + noteBytes := noteContentsMap[*notesMapping.NotesHash] + byteSlices := bytes.Split(noteBytes, []byte("\n")) + var notes []Note + for _, slice := range byteSlices { + notes = append(notes, Note(slice)) + } + commitNotesMap[*notesMapping.ObjectHash] = notes + } + + return commitNotesMap, nil +} + +// AppendNote appends a note to a revision under the given ref. +func (repo *GitRepo) AppendNote(notesRef, revision string, note Note) error { + _, err := repo.runGitCommand("notes", "--ref", notesRef, "append", "-m", string(note), revision) + return err +} + +// ListNotedRevisions returns the collection of revisions that are annotated by notes in the given ref. +func (repo *GitRepo) ListNotedRevisions(notesRef string) []string { + var revisions []string + notesListOut, err := repo.runGitCommand("notes", "--ref", notesRef, "list") + if err != nil { + return nil + } + notesList := strings.Split(notesListOut, "\n") + for _, notePair := range notesList { + noteParts := strings.SplitN(notePair, " ", 2) + if len(noteParts) == 2 { + objHash := noteParts[1] + objType, err := repo.runGitCommand("cat-file", "-t", objHash) + // If a note points to an object that we do not know about (yet), then err will not + // be nil. We can safely just ignore those notes. + if err == nil && objType == "commit" { + revisions = append(revisions, objHash) + } + } + } + return revisions +} + +// PushNotes pushes git notes to a remote repo. +func (repo *GitRepo) PushNotes(remote, notesRefPattern string) error { + refspec := fmt.Sprintf("%s:%s", notesRefPattern, notesRefPattern) + + // The push is liable to fail if the user forgot to do a pull first, so + // we treat errors as user errors rather than fatal errors. + err := repo.runGitCommandInline("push", remote, refspec) + if err != nil { + return fmt.Errorf("Failed to push to the remote '%s': %v", remote, err) + } + return nil +} + +// PushNotesAndArchive pushes the given notes and archive refs to a remote repo. +func (repo *GitRepo) PushNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error { + notesRefspec := fmt.Sprintf("%s:%s", notesRefPattern, notesRefPattern) + archiveRefspec := fmt.Sprintf("%s:%s", archiveRefPattern, archiveRefPattern) + err := repo.runGitCommandInline("push", remote, notesRefspec, archiveRefspec) + if err != nil { + return fmt.Errorf("Failed to push the local archive to the remote '%s': %v", remote, err) + } + return nil +} + +func getRemoteNotesRef(remote, localNotesRef string) string { + relativeNotesRef := strings.TrimPrefix(localNotesRef, "refs/notes/") + return "refs/notes/" + remote + "/" + relativeNotesRef +} + +// MergeNotes merges in the remote's state of the notes reference into the +// local repository's. +func (repo *GitRepo) MergeNotes(remote, notesRefPattern string) error { + remoteRefs, err := repo.runGitCommand("ls-remote", remote, notesRefPattern) + if err != nil { + return err + } + for _, line := range strings.Split(remoteRefs, "\n") { + lineParts := strings.Split(line, "\t") + if len(lineParts) == 2 { + ref := lineParts[1] + remoteRef := getRemoteNotesRef(remote, ref) + _, err := repo.runGitCommand("notes", "--ref", ref, "merge", remoteRef, "-s", "cat_sort_uniq") + if err != nil { + return err + } + } + } + return nil +} + +// PullNotes fetches the contents of the given notes ref from a remote repo, +// and then merges them with the corresponding local notes using the +// "cat_sort_uniq" strategy. +func (repo *GitRepo) PullNotes(remote, notesRefPattern string) error { + remoteNotesRefPattern := getRemoteNotesRef(remote, notesRefPattern) + fetchRefSpec := fmt.Sprintf("+%s:%s", notesRefPattern, remoteNotesRefPattern) + err := repo.runGitCommandInline("fetch", remote, fetchRefSpec) + if err != nil { + return err + } + + return repo.MergeNotes(remote, notesRefPattern) +} + +func getRemoteArchiveRef(remote, archiveRefPattern string) string { + relativeArchiveRef := strings.TrimPrefix(archiveRefPattern, "refs/devtools/archives/") + return "refs/devtools/remoteArchives/" + remote + "/" + relativeArchiveRef +} + +// MergeArchives merges in the remote's state of the archives reference into +// the local repository's. +func (repo *GitRepo) MergeArchives(remote, archiveRefPattern string) error { + remoteRefs, err := repo.runGitCommand("ls-remote", remote, archiveRefPattern) + if err != nil { + return err + } + for _, line := range strings.Split(remoteRefs, "\n") { + lineParts := strings.Split(line, "\t") + if len(lineParts) == 2 { + ref := lineParts[1] + remoteRef := getRemoteArchiveRef(remote, ref) + if err := repo.mergeArchives(ref, remoteRef); err != nil { + return err + } + } + } + return nil +} + +func (repo *GitRepo) fetchNotes(remote, notesRefPattern, + archiveRefPattern string) error { + + remoteArchiveRef := getRemoteArchiveRef(remote, archiveRefPattern) + archiveFetchRefSpec := fmt.Sprintf("+%s:%s", archiveRefPattern, remoteArchiveRef) + + remoteNotesRefPattern := getRemoteNotesRef(remote, notesRefPattern) + notesFetchRefSpec := fmt.Sprintf("+%s:%s", notesRefPattern, remoteNotesRefPattern) + + return repo.runGitCommandInline("fetch", remote, notesFetchRefSpec, archiveFetchRefSpec) +} + +// PullNotesAndArchive fetches the contents of the notes and archives refs from +// a remote repo, and merges them with the corresponding local refs. +// +// For notes refs, we assume that every note can be automatically merged using +// the 'cat_sort_uniq' strategy (the git-appraise schemas fit that requirement), +// so we automatically merge the remote notes into the local notes. +// +// For "archive" refs, they are expected to be used solely for maintaining +// reachability of commits that are part of the history of any reviews, +// so we do not maintain any consistency with their tree objects. Instead, +// we merely ensure that their history graph includes every commit that we +// intend to keep. +func (repo *GitRepo) PullNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error { + err := repo.fetchNotes(remote, notesRefPattern, archiveRefPattern) + if err != nil { + return err + } + + err = repo.MergeNotes(remote, notesRefPattern) + if err != nil { + return err + } + return repo.MergeArchives(remote, archiveRefPattern) +} + +// FetchAndReturnNewReviewHashes fetches the notes "branches" and then susses +// out the IDs (the revision the review points to) of any new reviews, then +// returns that list of IDs. +// +// This is accomplished by determining which files in the notes tree have +// changed because the _names_ of these files correspond to the revisions they +// point to. +func (repo *GitRepo) FetchAndReturnNewReviewHashes(remote, notesRefPattern, + archiveRefPattern string) ([]string, error) { + + // Record the current state of the reviews and comments refs. + var ( + getAllRevs, getAllComs bool + reviewsList, commentsList []string + ) + reviewBeforeHash, err := repo.GetCommitHash( + "notes/" + remote + "/devtools/reviews") + getAllRevs = err != nil + + commentBeforeHash, err := repo.GetCommitHash( + "notes/" + remote + "/devtools/discuss") + getAllComs = err != nil + + // Update them from the remote. + err = repo.fetchNotes(remote, notesRefPattern, archiveRefPattern) + if err != nil { + return nil, err + } + + // Now, if either of these are new refs, we just use the whole tree at that + // new ref. Otherwise we see which reviews or comments changed and collect + // them into a list. + if getAllRevs { + hash, err := repo.GetCommitHash( + "notes/" + remote + "/devtools/reviews") + // It is possible that even after we've pulled that this ref still + // isn't present (because there are no reviews yet). + if err == nil { + rvws, err := repo.runGitCommand("ls-tree", "-r", "--name-only", + hash) + if err != nil { + return nil, err + } + reviewsList = strings.Split(strings.Replace(rvws, "/", "", -1), + "\n") + } + } else { + reviewAfterHash, err := repo.GetCommitHash( + "notes/" + remote + "/devtools/reviews") + if err != nil { + return nil, err + } + + // Only run through this if the fetch fetched new revisions. + // Otherwise leave reviewsList as its default value, an empty slice + // of strings. + if reviewBeforeHash != reviewAfterHash { + newReviewsRaw, err := repo.runGitCommand("diff", "--name-only", + reviewBeforeHash, reviewAfterHash) + if err != nil { + return nil, err + } + reviewsList = strings.Split(strings.Replace(newReviewsRaw, + "/", "", -1), "\n") + } + } + + if getAllComs { + hash, err := repo.GetCommitHash( + "notes/" + remote + "/devtools/discuss") + // It is possible that even after we've pulled that this ref still + // isn't present (because there are no comments yet). + if err == nil { + rvws, err := repo.runGitCommand("ls-tree", "-r", "--name-only", + hash) + if err != nil { + return nil, err + } + commentsList = strings.Split(strings.Replace(rvws, "/", "", -1), + "\n") + } + } else { + commentAfterHash, err := repo.GetCommitHash( + "notes/" + remote + "/devtools/discuss") + if err != nil { + return nil, err + } + + // Only run through this if the fetch fetched new revisions. + // Otherwise leave commentsList as its default value, an empty slice + // of strings. + if commentBeforeHash != commentAfterHash { + newCommentsRaw, err := repo.runGitCommand("diff", "--name-only", + commentBeforeHash, commentAfterHash) + if err != nil { + return nil, err + } + commentsList = strings.Split(strings.Replace(newCommentsRaw, + "/", "", -1), "\n") + } + } + + // Now that we have our two lists, we need to merge them. + updatedReviewSet := make(map[string]struct{}) + for _, hash := range append(reviewsList, commentsList...) { + updatedReviewSet[hash] = struct{}{} + } + + updatedReviews := make([]string, 0, len(updatedReviewSet)) + for key, _ := range updatedReviewSet { + updatedReviews = append(updatedReviews, key) + } + return updatedReviews, nil +} diff --git a/third_party/go/git-appraise/repository/git_test.go b/third_party/go/git-appraise/repository/git_test.go new file mode 100644 index 000000000000..e1a9e2b2eace --- /dev/null +++ b/third_party/go/git-appraise/repository/git_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2016 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 repository + +import ( + "bytes" + "testing" +) + +const ( + simpleBatchCheckOutput = `ddbdcb9d5aa71d35de481789bacece9a2f8138d0 commit +de9ebcdf2a1e93365eefc2739f73f2c68a280c11 commit +def9abf52f9a17d4f168e05bc420557a87a55961 commit +df324616ea2bc9bf6fc7025fc80a373ecec687b6 missing +dfdd159c9c11c08d84c8c050d2a1a4db29147916 commit +e4e48e2b4d76ac305cf76fee1d1c8c0283127d71 commit +e6ae4ed08704fe3c258ab486b07a36e28c3c238a commit +e807a993d1807b154294b9875b9d926b6f246d0c commit +e90f75882526e9bc5a71af64d60ea50092ed0b1d commit` + simpleBatchCatFileOutput = `c1f5a5f135b171cc963b822d338000d185f1ae4f +342 +{"timestamp":"1450315153","v":0,"agent":"Jenkins(1.627) GitNotesJobLogger","url":"https://jenkins-dot-developer-tools-bundle.appspot.com/job/git-appraise/105/"} + +{"timestamp":"1450315161","v":0,"agent":"Jenkins(1.627) GitNotesJobLogger","url":"https://jenkins-dot-developer-tools-bundle.appspot.com/job/git-appraise/105/","status":"success"} + +31ea4952450bbe5db0d6a7a7903e451925106c0f +141 +{"timestamp":"1440202534","url":"https://travis-ci.org/google/git-appraise/builds/76722074","agent":"continuous-integration/travis-ci/push"} + +bde25250a9f6dc9c56f16befa5a2d73c8558b472 +342 +{"timestamp":"1450434854","v":0,"agent":"Jenkins(1.627) GitNotesJobLogger","url":"https://jenkins-dot-developer-tools-bundle.appspot.com/job/git-appraise/112/"} + +{"timestamp":"1450434860","v":0,"agent":"Jenkins(1.627) GitNotesJobLogger","url":"https://jenkins-dot-developer-tools-bundle.appspot.com/job/git-appraise/112/","status":"success"} + +3128dc6881bf7647aea90fef1f4fbf883df6a8fe +342 +{"timestamp":"1457445850","v":0,"agent":"Jenkins(1.627) GitNotesJobLogger","url":"https://jenkins-dot-developer-tools-bundle.appspot.com/job/git-appraise/191/"} + +{"timestamp":"1457445856","v":0,"agent":"Jenkins(1.627) GitNotesJobLogger","url":"https://jenkins-dot-developer-tools-bundle.appspot.com/job/git-appraise/191/","status":"success"} + +` +) + +func TestSplitBatchCheckOutput(t *testing.T) { + buf := bytes.NewBuffer([]byte(simpleBatchCheckOutput)) + commitsMap, err := splitBatchCheckOutput(buf) + if err != nil { + t.Fatal(err) + } + if !commitsMap["ddbdcb9d5aa71d35de481789bacece9a2f8138d0"] { + t.Fatal("Failed to recognize the first commit as valid") + } + if !commitsMap["de9ebcdf2a1e93365eefc2739f73f2c68a280c11"] { + t.Fatal("Failed to recognize the second commit as valid") + } + if !commitsMap["e90f75882526e9bc5a71af64d60ea50092ed0b1d"] { + t.Fatal("Failed to recognize the last commit as valid") + } + if commitsMap["df324616ea2bc9bf6fc7025fc80a373ecec687b6"] { + t.Fatal("Failed to filter out a missing object") + } +} + +func TestSplitBatchCatFileOutput(t *testing.T) { + buf := bytes.NewBuffer([]byte(simpleBatchCatFileOutput)) + notesMap, err := splitBatchCatFileOutput(buf) + if err != nil { + t.Fatal(err) + } + if len(notesMap["c1f5a5f135b171cc963b822d338000d185f1ae4f"]) != 342 { + t.Fatal("Failed to parse the contents of the first cat'ed file") + } + if len(notesMap["31ea4952450bbe5db0d6a7a7903e451925106c0f"]) != 141 { + t.Fatal("Failed to parse the contents of the second cat'ed file") + } + if len(notesMap["3128dc6881bf7647aea90fef1f4fbf883df6a8fe"]) != 342 { + t.Fatal("Failed to parse the contents of the last cat'ed file") + } +} diff --git a/third_party/go/git-appraise/repository/mock_repo.go b/third_party/go/git-appraise/repository/mock_repo.go new file mode 100644 index 000000000000..2d8debe48387 --- /dev/null +++ b/third_party/go/git-appraise/repository/mock_repo.go @@ -0,0 +1,613 @@ +/* +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 repository + +import ( + "crypto/sha1" + "encoding/json" + "fmt" + "strings" +) + +// Constants used for testing. +// We initialize our mock repo with two branches (one of which holds a pending review), +// and commit history that looks like this: +// +// Master Branch: A--B--D--E--F--J +// \ / \ \ +// C \ \ +// \ \ +// Review Branch: G--H--I +// +// Where commits "B" and "D" represent reviews that have been submitted, and "G" +// is a pending review. +const ( + TestTargetRef = "refs/heads/master" + TestReviewRef = "refs/heads/ojarjur/mychange" + TestAlternateReviewRef = "refs/review/mychange" + TestRequestsRef = "refs/notes/devtools/reviews" + TestCommentsRef = "refs/notes/devtools/discuss" + + TestCommitA = "A" + TestCommitB = "B" + TestCommitC = "C" + TestCommitD = "D" + TestCommitE = "E" + TestCommitF = "F" + TestCommitG = "G" + TestCommitH = "H" + TestCommitI = "I" + TestCommitJ = "J" + + TestRequestB = `{"timestamp": "0000000001", "reviewRef": "refs/heads/ojarjur/mychange", "targetRef": "refs/heads/master", "requester": "ojarjur", "reviewers": ["ojarjur"], "description": "B"}` + TestRequestD = `{"timestamp": "0000000002", "reviewRef": "refs/heads/ojarjur/mychange", "targetRef": "refs/heads/master", "requester": "ojarjur", "reviewers": ["ojarjur"], "description": "D"}` + TestRequestG = `{"timestamp": "0000000004", "reviewRef": "refs/heads/ojarjur/mychange", "targetRef": "refs/heads/master", "requester": "ojarjur", "reviewers": ["ojarjur"], "description": "G"} + +{"timestamp": "0000000005", "reviewRef": "refs/heads/ojarjur/mychange", "targetRef": "refs/heads/master", "requester": "ojarjur", "reviewers": ["ojarjur"], "description": "Updated description of G"} + +{"timestamp": "0000000005", "reviewRef": "refs/heads/ojarjur/mychange", "targetRef": "refs/heads/master", "requester": "ojarjur", "reviewers": ["ojarjur"], "description": "Final description of G"}` + + TestDiscussB = `{"timestamp": "0000000001", "author": "ojarjur", "location": {"commit": "B"}, "resolved": true}` + TestDiscussD = `{"timestamp": "0000000003", "author": "ojarjur", "location": {"commit": "E"}, "resolved": true}` +) + +type mockCommit struct { + Message string `json:"message,omitempty"` + Time string `json:"time,omitempty"` + Parents []string `json:"parents,omitempty"` +} + +// mockRepoForTest defines an instance of Repo that can be used for testing. +type mockRepoForTest struct { + Head string + Refs map[string]string `json:"refs,omitempty"` + Commits map[string]mockCommit `json:"commits,omitempty"` + Notes map[string]map[string]string `json:"notes,omitempty"` +} + +func (r *mockRepoForTest) createCommit(message string, time string, parents []string) (string, error) { + newCommit := mockCommit{ + Message: message, + Time: time, + Parents: parents, + } + newCommitJSON, err := json.Marshal(newCommit) + if err != nil { + return "", err + } + newCommitHash := fmt.Sprintf("%x", sha1.Sum([]byte(newCommitJSON))) + r.Commits[newCommitHash] = newCommit + return newCommitHash, nil +} + +// NewMockRepoForTest returns a mocked-out instance of the Repo interface that has been pre-populated with test data. +func NewMockRepoForTest() Repo { + commitA := mockCommit{ + Message: "First commit", + Time: "0", + Parents: nil, + } + commitB := mockCommit{ + Message: "Second commit", + Time: "1", + Parents: []string{TestCommitA}, + } + commitC := mockCommit{ + Message: "No, I'm the second commit", + Time: "1", + Parents: []string{TestCommitA}, + } + commitD := mockCommit{ + Message: "Fourth commit", + Time: "2", + Parents: []string{TestCommitB, TestCommitC}, + } + commitE := mockCommit{ + Message: "Fifth commit", + Time: "3", + Parents: []string{TestCommitD}, + } + commitF := mockCommit{ + Message: "Sixth commit", + Time: "4", + Parents: []string{TestCommitE}, + } + commitG := mockCommit{ + Message: "No, I'm the sixth commit", + Time: "4", + Parents: []string{TestCommitE}, + } + commitH := mockCommit{ + Message: "Seventh commit", + Time: "5", + Parents: []string{TestCommitG, TestCommitF}, + } + commitI := mockCommit{ + Message: "Eighth commit", + Time: "6", + Parents: []string{TestCommitH}, + } + commitJ := mockCommit{ + Message: "No, I'm the eighth commit", + Time: "6", + Parents: []string{TestCommitF}, + } + return &mockRepoForTest{ + Head: TestTargetRef, + Refs: map[string]string{ + TestTargetRef: TestCommitJ, + TestReviewRef: TestCommitI, + TestAlternateReviewRef: TestCommitI, + }, + Commits: map[string]mockCommit{ + TestCommitA: commitA, + TestCommitB: commitB, + TestCommitC: commitC, + TestCommitD: commitD, + TestCommitE: commitE, + TestCommitF: commitF, + TestCommitG: commitG, + TestCommitH: commitH, + TestCommitI: commitI, + TestCommitJ: commitJ, + }, + Notes: map[string]map[string]string{ + TestRequestsRef: map[string]string{ + TestCommitB: TestRequestB, + TestCommitD: TestRequestD, + TestCommitG: TestRequestG, + }, + TestCommentsRef: map[string]string{ + TestCommitB: TestDiscussB, + TestCommitD: TestDiscussD, + }, + }, + } +} + +// GetPath returns the path to the repo. +func (r *mockRepoForTest) GetPath() string { return "~/mockRepo/" } + +// GetRepoStateHash returns a hash which embodies the entire current state of a repository. +func (r *mockRepoForTest) GetRepoStateHash() (string, error) { + repoJSON, err := json.Marshal(r) + if err != nil { + return "", err + } + return fmt.Sprintf("%x", sha1.Sum([]byte(repoJSON))), nil +} + +// GetUserEmail returns the email address that the user has used to configure git. +func (r *mockRepoForTest) GetUserEmail() (string, error) { return "user@example.com", nil } + +// GetUserSigningKey returns the key id the user has configured for +// sigining git artifacts. +func (r *mockRepoForTest) GetUserSigningKey() (string, error) { + return "gpgsig", nil +} + +// GetCoreEditor returns the name of the editor that the user has used to configure git. +func (r *mockRepoForTest) GetCoreEditor() (string, error) { return "vi", nil } + +// GetSubmitStrategy returns the way in which a review is submitted +func (r *mockRepoForTest) GetSubmitStrategy() (string, error) { return "merge", nil } + +// HasUncommittedChanges returns true if there are local, uncommitted changes. +func (r *mockRepoForTest) HasUncommittedChanges() (bool, error) { return false, nil } + +func (r *mockRepoForTest) resolveLocalRef(ref string) (string, error) { + if ref == "HEAD" { + ref = r.Head + } + if commit, ok := r.Refs[ref]; ok { + return commit, nil + } + if _, ok := r.Commits[ref]; ok { + return ref, nil + } + return "", fmt.Errorf("The ref %q does not exist", ref) +} + +// VerifyCommit verifies that the supplied hash points to a known commit. +func (r *mockRepoForTest) VerifyCommit(hash string) error { + if _, ok := r.Commits[hash]; !ok { + return fmt.Errorf("The given hash %q is not a known commit", hash) + } + return nil +} + +// VerifyGitRef verifies that the supplied ref points to a known commit. +func (r *mockRepoForTest) VerifyGitRef(ref string) error { + _, err := r.resolveLocalRef(ref) + return err +} + +// GetHeadRef returns the ref that is the current HEAD. +func (r *mockRepoForTest) GetHeadRef() (string, error) { return r.Head, nil } + +// GetCommitHash returns the hash of the commit pointed to by the given ref. +func (r *mockRepoForTest) GetCommitHash(ref string) (string, error) { + err := r.VerifyGitRef(ref) + if err != nil { + return "", err + } + return r.resolveLocalRef(ref) +} + +// ResolveRefCommit returns the commit pointed to by the given ref, which may be a remote ref. +// +// This differs from GetCommitHash which only works on exact matches, in that it will try to +// intelligently handle the scenario of a ref not existing locally, but being known to exist +// in a remote repo. +// +// This method should be used when a command may be performed by either the reviewer or the +// reviewee, while GetCommitHash should be used when the encompassing command should only be +// performed by the reviewee. +func (r *mockRepoForTest) ResolveRefCommit(ref string) (string, error) { + if commit, err := r.resolveLocalRef(ref); err == nil { + return commit, err + } + return r.resolveLocalRef(strings.Replace(ref, "refs/heads/", "refs/remotes/origin/", 1)) +} + +func (r *mockRepoForTest) getCommit(ref string) (mockCommit, error) { + commit, err := r.resolveLocalRef(ref) + return r.Commits[commit], err +} + +// GetCommitMessage returns the message stored in the commit pointed to by the given ref. +func (r *mockRepoForTest) GetCommitMessage(ref string) (string, error) { + commit, err := r.getCommit(ref) + if err != nil { + return "", err + } + return commit.Message, nil +} + +// GetCommitTime returns the commit time of the commit pointed to by the given ref. +func (r *mockRepoForTest) GetCommitTime(ref string) (string, error) { + commit, err := r.getCommit(ref) + if err != nil { + return "", err + } + return commit.Time, nil +} + +// GetLastParent returns the last parent of the given commit (as ordered by git). +func (r *mockRepoForTest) GetLastParent(ref string) (string, error) { + commit, err := r.getCommit(ref) + if len(commit.Parents) > 0 { + return commit.Parents[len(commit.Parents)-1], err + } + return "", err +} + +// GetCommitDetails returns the details of a commit's metadata. +func (r *mockRepoForTest) GetCommitDetails(ref string) (*CommitDetails, error) { + commit, err := r.getCommit(ref) + if err != nil { + return nil, err + } + var details CommitDetails + details.Author = "Test Author" + details.AuthorEmail = "author@example.com" + details.Summary = commit.Message + details.Time = commit.Time + details.Parents = commit.Parents + return &details, nil +} + +// ancestors returns the breadth-first traversal of a commit's ancestors +func (r *mockRepoForTest) ancestors(commit string) ([]string, error) { + queue := []string{commit} + var ancestors []string + for queue != nil { + var nextQueue []string + for _, c := range queue { + commit, err := r.getCommit(c) + if err != nil { + return nil, err + } + parents := commit.Parents + nextQueue = append(nextQueue, parents...) + ancestors = append(ancestors, parents...) + } + queue = nextQueue + } + return ancestors, nil +} + +// IsAncestor determines if the first argument points to a commit that is an ancestor of the second. +func (r *mockRepoForTest) IsAncestor(ancestor, descendant string) (bool, error) { + var err error + ancestor, err = r.resolveLocalRef(ancestor) + if err != nil { + return false, err + } + descendant, err = r.resolveLocalRef(descendant) + if err != nil { + return false, err + } + if ancestor == descendant { + return true, nil + } + descendantCommit, err := r.getCommit(descendant) + if err != nil { + return false, err + } + for _, parent := range descendantCommit.Parents { + if t, e := r.IsAncestor(ancestor, parent); e == nil && t { + return true, nil + } + } + return false, nil +} + +// MergeBase determines if the first commit that is an ancestor of the two arguments. +func (r *mockRepoForTest) MergeBase(a, b string) (string, error) { + ancestors, err := r.ancestors(a) + if err != nil { + return "", err + } + for _, ancestor := range ancestors { + if t, e := r.IsAncestor(ancestor, b); e == nil && t { + return ancestor, nil + } + } + return "", nil +} + +// Diff computes the diff between two given commits. +func (r *mockRepoForTest) Diff(left, right string, diffArgs ...string) (string, error) { + return fmt.Sprintf("Diff between %q and %q", left, right), nil +} + +// Show returns the contents of the given file at the given commit. +func (r *mockRepoForTest) Show(commit, path string) (string, error) { + return fmt.Sprintf("%s:%s", commit, path), nil +} + +// SwitchToRef changes the currently-checked-out ref. +func (r *mockRepoForTest) SwitchToRef(ref string) error { + r.Head = ref + return nil +} + +// ArchiveRef adds the current commit pointed to by the 'ref' argument +// under the ref specified in the 'archive' argument. +// +// Both the 'ref' and 'archive' arguments are expected to be the fully +// qualified names of git refs (e.g. 'refs/heads/my-change' or +// 'refs/archive/devtools'). +// +// If the ref pointed to by the 'archive' argument does not exist +// yet, then it will be created. +func (r *mockRepoForTest) ArchiveRef(ref, archive string) error { + commitToArchive, err := r.resolveLocalRef(ref) + if err != nil { + return err + } + var archiveParents []string + if archiveCommit, err := r.resolveLocalRef(archive); err == nil { + archiveParents = []string{archiveCommit, commitToArchive} + } else { + archiveParents = []string{commitToArchive} + } + archiveCommit, err := r.createCommit("Archiving", "Nowish", archiveParents) + if err != nil { + return err + } + r.Refs[archive] = archiveCommit + return nil +} + +// MergeRef merges the given ref into the current one. +// +// The ref argument is the ref to merge, and fastForward indicates that the +// current ref should only move forward, as opposed to creating a bubble merge. +func (r *mockRepoForTest) MergeRef(ref string, fastForward bool, messages ...string) error { + newCommitHash, err := r.resolveLocalRef(ref) + if err != nil { + return err + } + if !fastForward { + origCommit, err := r.resolveLocalRef(r.Head) + if err != nil { + return err + } + newCommit, err := r.getCommit(ref) + if err != nil { + return err + } + message := strings.Join(messages, "\n\n") + time := newCommit.Time + parents := []string{origCommit, newCommitHash} + newCommitHash, err = r.createCommit(message, time, parents) + if err != nil { + return err + } + } + r.Refs[r.Head] = newCommitHash + return nil +} + +// MergeAndSignRef merges the given ref into the current one and signs the +// merge. +// +// The ref argument is the ref to merge, and fastForward indicates that the +// current ref should only move forward, as opposed to creating a bubble merge. +func (r *mockRepoForTest) MergeAndSignRef(ref string, fastForward bool, + messages ...string) error { + return nil +} + +// RebaseRef rebases the current ref onto the given one. +func (r *mockRepoForTest) RebaseRef(ref string) error { + parentHash := r.Refs[ref] + origCommit, err := r.getCommit(r.Head) + if err != nil { + return err + } + newCommitHash, err := r.createCommit(origCommit.Message, origCommit.Time, []string{parentHash}) + if err != nil { + return err + } + if strings.HasPrefix(r.Head, "refs/heads/") { + r.Refs[r.Head] = newCommitHash + } else { + // The current head is not a branch, so updating + // it should leave us in a detached-head state. + r.Head = newCommitHash + } + return nil +} + +// RebaseAndSignRef rebases the current ref onto the given one and signs the +// result. +func (r *mockRepoForTest) RebaseAndSignRef(ref string) error { return nil } + +// ListCommits returns the list of commits reachable from the given ref. +// +// The generated list is in chronological order (with the oldest commit first). +// +// If the specified ref does not exist, then this method returns an empty result. +func (r *mockRepoForTest) ListCommits(ref string) []string { return nil } + +// ListCommitsBetween returns the list of commits between the two given revisions. +// +// The "from" parameter is the starting point (exclusive), and the "to" +// parameter is the ending point (inclusive). +// +// The "from" commit does not need to be an ancestor of the "to" commit. If it +// is not, then the merge base of the two is used as the starting point. +// Admittedly, this makes calling these the "between" commits is a bit of a +// misnomer, but it also makes the method easier to use when you want to +// generate the list of changes in a feature branch, as it eliminates the need +// to explicitly calculate the merge base. This also makes the semantics of the +// method compatible with git's built-in "rev-list" command. +// +// The generated list is in chronological order (with the oldest commit first). +func (r *mockRepoForTest) ListCommitsBetween(from, to string) ([]string, error) { + commits := []string{to} + potentialCommits, _ := r.ancestors(to) + for _, commit := range potentialCommits { + blocked, err := r.IsAncestor(commit, from) + if err != nil { + return nil, err + } + if !blocked { + commits = append(commits, commit) + } + } + return commits, nil +} + +// GetNotes reads the notes from the given ref that annotate the given revision. +func (r *mockRepoForTest) GetNotes(notesRef, revision string) []Note { + notesText := r.Notes[notesRef][revision] + var notes []Note + for _, line := range strings.Split(notesText, "\n") { + notes = append(notes, Note(line)) + } + return notes +} + +// GetAllNotes reads the contents of the notes under the given ref for every commit. +// +// The returned value is a mapping from commit hash to the list of notes for that commit. +// +// This is the batch version of the corresponding GetNotes(...) method. +func (r *mockRepoForTest) GetAllNotes(notesRef string) (map[string][]Note, error) { + notesMap := make(map[string][]Note) + for _, commit := range r.ListNotedRevisions(notesRef) { + notesMap[commit] = r.GetNotes(notesRef, commit) + } + return notesMap, nil +} + +// AppendNote appends a note to a revision under the given ref. +func (r *mockRepoForTest) AppendNote(ref, revision string, note Note) error { + existingNotes := r.Notes[ref][revision] + newNotes := existingNotes + "\n" + string(note) + r.Notes[ref][revision] = newNotes + return nil +} + +// ListNotedRevisions returns the collection of revisions that are annotated by notes in the given ref. +func (r *mockRepoForTest) ListNotedRevisions(notesRef string) []string { + var revisions []string + for revision := range r.Notes[notesRef] { + if _, ok := r.Commits[revision]; ok { + revisions = append(revisions, revision) + } + } + return revisions +} + +// PushNotes pushes git notes to a remote repo. +func (r *mockRepoForTest) PushNotes(remote, notesRefPattern string) error { return nil } + +// PullNotes fetches the contents of the given notes ref from a remote repo, +// and then merges them with the corresponding local notes using the +// "cat_sort_uniq" strategy. +func (r *mockRepoForTest) PullNotes(remote, notesRefPattern string) error { return nil } + +// PushNotesAndArchive pushes the given notes and archive refs to a remote repo. +func (r *mockRepoForTest) PushNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error { + return nil +} + +// PullNotesAndArchive fetches the contents of the notes and archives refs from +// a remote repo, and merges them with the corresponding local refs. +// +// For notes refs, we assume that every note can be automatically merged using +// the 'cat_sort_uniq' strategy (the git-appraise schemas fit that requirement), +// so we automatically merge the remote notes into the local notes. +// +// For "archive" refs, they are expected to be used solely for maintaining +// reachability of commits that are part of the history of any reviews, +// so we do not maintain any consistency with their tree objects. Instead, +// we merely ensure that their history graph includes every commit that we +// intend to keep. +func (r *mockRepoForTest) PullNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error { + return nil +} + +// MergeNotes merges in the remote's state of the archives reference into +// the local repository's. +func (repo *mockRepoForTest) MergeNotes(remote, notesRefPattern string) error { + return nil +} + +// MergeArchives merges in the remote's state of the archives reference into +// the local repository's. +func (repo *mockRepoForTest) MergeArchives(remote, + archiveRefPattern string) error { + return nil +} + +// FetchAndReturnNewReviewHashes fetches the notes "branches" and then susses +// out the IDs (the revision the review points to) of any new reviews, then +// returns that list of IDs. +// +// This is accomplished by determining which files in the notes tree have +// changed because the _names_ of these files correspond to the revisions they +// point to. +func (repo *mockRepoForTest) FetchAndReturnNewReviewHashes(remote, notesRefPattern, + archiveRefPattern string) ([]string, error) { + return nil, nil +} diff --git a/third_party/go/git-appraise/repository/repo.go b/third_party/go/git-appraise/repository/repo.go new file mode 100644 index 000000000000..91acd177edf0 --- /dev/null +++ b/third_party/go/git-appraise/repository/repo.go @@ -0,0 +1,221 @@ +/* +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 repository contains helper methods for working with a Git repo. +package repository + +// Note represents the contents of a git-note +type Note []byte + +// CommitDetails represents the contents of a commit. +type CommitDetails struct { + Author string `json:"author,omitempty"` + AuthorEmail string `json:"authorEmail,omitempty"` + Tree string `json:"tree,omitempty"` + Time string `json:"time,omitempty"` + Parents []string `json:"parents,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// Repo represents a source code repository. +type Repo interface { + // GetPath returns the path to the repo. + GetPath() string + + // GetRepoStateHash returns a hash which embodies the entire current state of a repository. + GetRepoStateHash() (string, error) + + // GetUserEmail returns the email address that the user has used to configure git. + GetUserEmail() (string, error) + + // GetUserSigningKey returns the key id the user has configured for + // sigining git artifacts. + GetUserSigningKey() (string, error) + + // GetCoreEditor returns the name of the editor that the user has used to configure git. + GetCoreEditor() (string, error) + + // GetSubmitStrategy returns the way in which a review is submitted + GetSubmitStrategy() (string, error) + + // HasUncommittedChanges returns true if there are local, uncommitted changes. + HasUncommittedChanges() (bool, error) + + // VerifyCommit verifies that the supplied hash points to a known commit. + VerifyCommit(hash string) error + + // VerifyGitRef verifies that the supplied ref points to a known commit. + VerifyGitRef(ref string) error + + // GetHeadRef returns the ref that is the current HEAD. + GetHeadRef() (string, error) + + // GetCommitHash returns the hash of the commit pointed to by the given ref. + GetCommitHash(ref string) (string, error) + + // ResolveRefCommit returns the commit pointed to by the given ref, which may be a remote ref. + // + // This differs from GetCommitHash which only works on exact matches, in that it will try to + // intelligently handle the scenario of a ref not existing locally, but being known to exist + // in a remote repo. + // + // This method should be used when a command may be performed by either the reviewer or the + // reviewee, while GetCommitHash should be used when the encompassing command should only be + // performed by the reviewee. + ResolveRefCommit(ref string) (string, error) + + // GetCommitMessage returns the message stored in the commit pointed to by the given ref. + GetCommitMessage(ref string) (string, error) + + // GetCommitTime returns the commit time of the commit pointed to by the given ref. + GetCommitTime(ref string) (string, error) + + // GetLastParent returns the last parent of the given commit (as ordered by git). + GetLastParent(ref string) (string, error) + + // GetCommitDetails returns the details of a commit's metadata. + GetCommitDetails(ref string) (*CommitDetails, error) + + // MergeBase determines if the first commit that is an ancestor of the two arguments. + MergeBase(a, b string) (string, error) + + // IsAncestor determines if the first argument points to a commit that is an ancestor of the second. + IsAncestor(ancestor, descendant string) (bool, error) + + // Diff computes the diff between two given commits. + Diff(left, right string, diffArgs ...string) (string, error) + + // Show returns the contents of the given file at the given commit. + Show(commit, path string) (string, error) + + // SwitchToRef changes the currently-checked-out ref. + SwitchToRef(ref string) error + + // ArchiveRef adds the current commit pointed to by the 'ref' argument + // under the ref specified in the 'archive' argument. + // + // Both the 'ref' and 'archive' arguments are expected to be the fully + // qualified names of git refs (e.g. 'refs/heads/my-change' or + // 'refs/archive/devtools'). + // + // If the ref pointed to by the 'archive' argument does not exist + // yet, then it will be created. + ArchiveRef(ref, archive string) error + + // MergeRef merges the given ref into the current one. + // + // The ref argument is the ref to merge, and fastForward indicates that the + // current ref should only move forward, as opposed to creating a bubble merge. + // The messages argument(s) provide text that should be included in the default + // merge commit message (separated by blank lines). + MergeRef(ref string, fastForward bool, messages ...string) error + + // MergeAndSignRef merges the given ref into the current one and signs the + // merge. + // + // The ref argument is the ref to merge, and fastForward indicates that the + // current ref should only move forward, as opposed to creating a bubble merge. + // The messages argument(s) provide text that should be included in the default + // merge commit message (separated by blank lines). + MergeAndSignRef(ref string, fastForward bool, messages ...string) error + + // RebaseRef rebases the current ref onto the given one. + RebaseRef(ref string) error + + // RebaseAndSignRef rebases the current ref onto the given one and signs + // the result. + RebaseAndSignRef(ref string) error + + // ListCommits returns the list of commits reachable from the given ref. + // + // The generated list is in chronological order (with the oldest commit first). + // + // If the specified ref does not exist, then this method returns an empty result. + ListCommits(ref string) []string + + // ListCommitsBetween returns the list of commits between the two given revisions. + // + // The "from" parameter is the starting point (exclusive), and the "to" + // parameter is the ending point (inclusive). + // + // The "from" commit does not need to be an ancestor of the "to" commit. If it + // is not, then the merge base of the two is used as the starting point. + // Admittedly, this makes calling these the "between" commits is a bit of a + // misnomer, but it also makes the method easier to use when you want to + // generate the list of changes in a feature branch, as it eliminates the need + // to explicitly calculate the merge base. This also makes the semantics of the + // method compatible with git's built-in "rev-list" command. + // + // The generated list is in chronological order (with the oldest commit first). + ListCommitsBetween(from, to string) ([]string, error) + + // GetNotes reads the notes from the given ref that annotate the given revision. + GetNotes(notesRef, revision string) []Note + + // GetAllNotes reads the contents of the notes under the given ref for every commit. + // + // The returned value is a mapping from commit hash to the list of notes for that commit. + // + // This is the batch version of the corresponding GetNotes(...) method. + GetAllNotes(notesRef string) (map[string][]Note, error) + + // AppendNote appends a note to a revision under the given ref. + AppendNote(ref, revision string, note Note) error + + // ListNotedRevisions returns the collection of revisions that are annotated by notes in the given ref. + ListNotedRevisions(notesRef string) []string + + // PushNotes pushes git notes to a remote repo. + PushNotes(remote, notesRefPattern string) error + + // PullNotes fetches the contents of the given notes ref from a remote repo, + // and then merges them with the corresponding local notes using the + // "cat_sort_uniq" strategy. + PullNotes(remote, notesRefPattern string) error + + // PushNotesAndArchive pushes the given notes and archive refs to a remote repo. + PushNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error + + // PullNotesAndArchive fetches the contents of the notes and archives refs from + // a remote repo, and merges them with the corresponding local refs. + // + // For notes refs, we assume that every note can be automatically merged using + // the 'cat_sort_uniq' strategy (the git-appraise schemas fit that requirement), + // so we automatically merge the remote notes into the local notes. + // + // For "archive" refs, they are expected to be used solely for maintaining + // reachability of commits that are part of the history of any reviews, + // so we do not maintain any consistency with their tree objects. Instead, + // we merely ensure that their history graph includes every commit that we + // intend to keep. + PullNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error + + // MergeNotes merges in the remote's state of the archives reference into + // the local repository's. + MergeNotes(remote, notesRefPattern string) error + // MergeArchives merges in the remote's state of the archives reference + // into the local repository's. + MergeArchives(remote, archiveRefPattern string) error + + // FetchAndReturnNewReviewHashes fetches the notes "branches" and then + // susses out the IDs (the revision the review points to) of any new + // reviews, then returns that list of IDs. + // + // This is accomplished by determining which files in the notes tree have + // changed because the _names_ of these files correspond to the revisions + // they point to. + FetchAndReturnNewReviewHashes(remote, notesRefPattern, archiveRefPattern string) ([]string, error) +} 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) + } +} diff --git a/third_party/go/git-appraise/schema/analysis.json b/third_party/go/git-appraise/schema/analysis.json new file mode 100644 index 000000000000..cbecb5416bc9 --- /dev/null +++ b/third_party/go/git-appraise/schema/analysis.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + + "properties": { + "timestamp": { + "description": "the number of seconds since the Unix epoch", + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-9]{10,10}" + }, + + "status": { + "description": "represents the overall status of all messages from the analysis results", + "oneOf": [{ + "$ref": "#/definitions/lgtm" + }, { + "$ref": "#/definitions/fyi" + }, { + "$ref": "#/definitions/nmw" + }] + }, + + "url": { + "description": "a publicly readable file, which contains JSON formatted analysis results. Those results should conform to the JSON format of the ShipshapeResponse protocol buffer message defined https://github.com/google/shipshape/blob/master/shipshape/proto/shipshape_rpc.proto", + "type": "string" + }, + + "v": { + "type": "integer", + "enum": [0] + } + }, + + "required": [ + "timestamp", + "url" + ], + + "definitions": { + "lgtm": { + "title": "Looks Good To Me", + "description": "indicates the analysis produced no messages", + "type": "string", + "enum": ["lgtm"] + }, + "fyi": { + "title": "For your information", + "description": "indicates the analysis produced some messages, but none of them indicate errors", + "type": "string", + "enum": ["fyi"] + }, + "nmw": { + "title": "Needs more work", + "description": "indicates the analysis produced at least one message indicating an error", + "type": "string", + "enum": ["nmw"] + } + } +} diff --git a/third_party/go/git-appraise/schema/ci.json b/third_party/go/git-appraise/schema/ci.json new file mode 100644 index 000000000000..7436408290ce --- /dev/null +++ b/third_party/go/git-appraise/schema/ci.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + + "properties": { + "timestamp": { + "description": "the number of seconds since the Unix epoch", + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-9]{10,10}" + }, + + "agent": { + "description": "a free-form string that identifies the build and test runner", + "type": "string" + }, + + "status": { + "description": "the final status of a build or test", + "type": "string", + "enum": [ + "success", + "failure" + ] + }, + + "url": { + "type": "string" + }, + + "v": { + "type": "integer", + "enum": [0] + } + }, + + "required": [ + "timestamp", + "agent" + ] +} diff --git a/third_party/go/git-appraise/schema/comment.json b/third_party/go/git-appraise/schema/comment.json new file mode 100644 index 000000000000..a39b1a2e670b --- /dev/null +++ b/third_party/go/git-appraise/schema/comment.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + + "properties": { + "timestamp": { + "description": "the number of seconds since the Unix epoch", + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-9]{10,10}" + }, + + "author": { + "type": "string" + }, + + "original": { + "description": "the SHA1 hash of another comment on the same revision, and it means this comment is an updated version of that comment", + "type": "string" + }, + + "parent": { + "description": "the SHA1 hash of another comment on the same revision, and it means this comment is a reply to that comment", + "type": "string" + }, + + "location": { + "type": "object", + "properties": { + "commit": { + "type": "string" + }, + "path": { + "type": "string" + }, + "range": { + "type": "object", + "properties": { + "startLine": { + "type": "integer" + }, + "startColumn": { + "type": "integer" + }, + "endLine": { + "type": "integer" + }, + "endColumn": { + "type": "integer" + } + } + } + } + }, + + "description": { + "type": "string" + }, + + "resolved": { + "type": "boolean" + }, + + "v": { + "type": "integer", + "enum": [0] + } + }, + + "required": [ + "timestamp", + "author" + ] +} diff --git a/third_party/go/git-appraise/schema/request.json b/third_party/go/git-appraise/schema/request.json new file mode 100644 index 000000000000..9ec022a16e9e --- /dev/null +++ b/third_party/go/git-appraise/schema/request.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + + "properties": { + "timestamp": { + "description": "the number of seconds since the Unix epoch", + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-9]{10,10}" + }, + + "requester": { + "type": "string" + }, + + "baseCommit": { + "type": "string" + }, + + "reviewRef": { + "description": "used to specify a git ref that tracks the current revision under review", + "type": "string" + }, + + "targetRef": { + "description": "used to specify the git ref that should be updated once the review is approved", + "type": "string" + }, + + "reviewers": { + "type": "array", + "items": { + "type": "string" + } + }, + + "description": { + "type": "string" + }, + + "v": { + "type": "integer", + "enum": [0] + }, + + "alias": { + "description": "used to specify a post-rebase commit hash for the review", + "type": "string" + } + }, + + "required": [ + "timestamp", + "requester" + ] +} |