feat(third_party): Check in git-appraise

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

View file

@ -0,0 +1,2 @@
*~
bin/

View file

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

202
third_party/go/git-appraise/LICENSE vendored Normal file
View file

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

158
third_party/go/git-appraise/README.md vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,85 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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)
},
}

View file

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

View file

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

View file

@ -0,0 +1,104 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// 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)
}
}

View file

@ -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, &notesMapping{
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 &notesOverview{
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
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,160 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package analyses defines the internal representation of static analysis reports.
package analyses
import (
"encoding/json"
"io/ioutil"
"net/http"
"sort"
"strconv"
"github.com/google/git-appraise/repository"
)
const (
// Ref defines the git-notes ref that we expect to contain analysis reports.
Ref = "refs/notes/devtools/analyses"
// StatusLooksGoodToMe is the status string representing that analyses reported no messages.
StatusLooksGoodToMe = "lgtm"
// StatusForYourInformation is the status string representing that analyses reported informational messages.
StatusForYourInformation = "fyi"
// StatusNeedsMoreWork is the status string representing that analyses reported error messages.
StatusNeedsMoreWork = "nmw"
// FormatVersion defines the latest version of the request format supported by the tool.
FormatVersion = 0
)
// Report represents a build/test status report generated by analyses tool.
// Every field is optional.
type Report struct {
Timestamp string `json:"timestamp,omitempty"`
URL string `json:"url,omitempty"`
Status string `json:"status,omitempty"`
// Version represents the version of the metadata format.
Version int `json:"v,omitempty"`
}
// LocationRange represents the location within a source file that an analysis message covers.
type LocationRange struct {
StartLine uint32 `json:"start_line,omitempty"`
StartColumn uint32 `json:"start_column,omitempty"`
EndLine uint32 `json:"end_line,omitempty"`
EndColumn uint32 `json:"end_column,omitempty"`
}
// Location represents the location within a source tree that an analysis message covers.
type Location struct {
Path string `json:"path,omitempty"`
Range *LocationRange `json:"range,omitempty"`
}
// Note represents a single analysis message.
type Note struct {
Location *Location `json:"location,omitempty"`
Category string `json:"category,omitempty"`
Description string `json:"description"`
}
// AnalyzeResponse represents the response from a static-analysis tool.
type AnalyzeResponse struct {
Notes []Note `json:"note,omitempty"`
}
// ReportDetails represents an entire static analysis run (which might include multiple analysis tools).
type ReportDetails struct {
AnalyzeResponse []AnalyzeResponse `json:"analyze_response,omitempty"`
}
// GetLintReportResult downloads the details of a lint report and returns the responses embedded in it.
func (analysesReport Report) GetLintReportResult() ([]AnalyzeResponse, error) {
if analysesReport.URL == "" {
return nil, nil
}
res, err := http.Get(analysesReport.URL)
if err != nil {
return nil, err
}
analysesResults, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return nil, err
}
var details ReportDetails
err = json.Unmarshal([]byte(analysesResults), &details)
if err != nil {
return nil, err
}
return details.AnalyzeResponse, nil
}
// GetNotes downloads the details of an analyses report and returns the notes embedded in it.
func (analysesReport Report) GetNotes() ([]Note, error) {
reportResults, err := analysesReport.GetLintReportResult()
if err != nil {
return nil, err
}
var reportNotes []Note
for _, reportResult := range reportResults {
reportNotes = append(reportNotes, reportResult.Notes...)
}
return reportNotes, nil
}
// Parse parses an analysis report from a git note.
func Parse(note repository.Note) (Report, error) {
bytes := []byte(note)
var report Report
err := json.Unmarshal(bytes, &report)
return report, err
}
// GetLatestAnalysesReport takes a collection of analysis reports, and returns the one with the most recent timestamp.
func GetLatestAnalysesReport(reports []Report) (*Report, error) {
timestampReportMap := make(map[int]*Report)
var timestamps []int
for _, report := range reports {
timestamp, err := strconv.Atoi(report.Timestamp)
if err != nil {
return nil, err
}
timestamps = append(timestamps, timestamp)
timestampReportMap[timestamp] = &report
}
if len(timestamps) == 0 {
return nil, nil
}
sort.Sort(sort.Reverse(sort.IntSlice(timestamps)))
return timestampReportMap[timestamps[0]], nil
}
// ParseAllValid takes collection of git notes and tries to parse a analyses report
// from each one. Any notes that are not valid analyses reports get ignored.
func ParseAllValid(notes []repository.Note) []Report {
var reports []Report
for _, note := range notes {
report, err := Parse(note)
if err == nil && report.Version == FormatVersion {
reports = append(reports, report)
}
}
return reports
}

View file

@ -0,0 +1,77 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyses
import (
"fmt"
"github.com/google/git-appraise/repository"
"net/http"
"net/http/httptest"
"testing"
)
const (
mockOldReport = `{"timestamp": "0", "url": "https://this-url-does-not-exist.test/analysis.json"}`
mockNewReport = `{"timestamp": "1", "url": "%s"}`
mockResults = `{
"analyze_response": [{
"note": [{
"location": {
"path": "file.txt",
"range": {
"start_line": 5
}
},
"category": "test",
"description": "This is a test"
}]
}]
}`
)
func mockHandler(t *testing.T) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
t.Log(r)
fmt.Fprintln(w, mockResults)
w.WriteHeader(http.StatusOK)
}
}
func TestGetLatestResult(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(mockHandler(t)))
defer mockServer.Close()
reports := ParseAllValid([]repository.Note{
repository.Note([]byte(mockOldReport)),
repository.Note([]byte(fmt.Sprintf(mockNewReport, mockServer.URL))),
})
report, err := GetLatestAnalysesReport(reports)
if err != nil {
t.Fatal("Unexpected error while parsing analysis reports", err)
}
if report == nil {
t.Fatal("Unexpected nil report")
}
reportResult, err := report.GetLintReportResult()
if err != nil {
t.Fatal("Unexpected error while reading the latest report's results", err)
}
if len(reportResult) != 1 {
t.Fatal("Unexpected report result", reportResult)
}
}

View file

@ -0,0 +1,95 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package ci defines the internal representation of a continuous integration reports.
package ci
import (
"encoding/json"
"github.com/google/git-appraise/repository"
"sort"
"strconv"
)
const (
// Ref defines the git-notes ref that we expect to contain CI reports.
Ref = "refs/notes/devtools/ci"
// StatusSuccess is the status string representing that a build and/or test passed.
StatusSuccess = "success"
// StatusFailure is the status string representing that a build and/or test failed.
StatusFailure = "failure"
// FormatVersion defines the latest version of the request format supported by the tool.
FormatVersion = 0
)
// Report represents a build/test status report generated by a continuous integration tool.
//
// Every field is optional.
type Report struct {
Timestamp string `json:"timestamp,omitempty"`
URL string `json:"url,omitempty"`
Status string `json:"status,omitempty"`
Agent string `json:"agent,omitempty"`
// Version represents the version of the metadata format.
Version int `json:"v,omitempty"`
}
// Parse parses a CI report from a git note.
func Parse(note repository.Note) (Report, error) {
bytes := []byte(note)
var report Report
err := json.Unmarshal(bytes, &report)
return report, err
}
// GetLatestCIReport takes the collection of reports and returns the one with the most recent timestamp.
func GetLatestCIReport(reports []Report) (*Report, error) {
timestampReportMap := make(map[int]*Report)
var timestamps []int
for _, report := range reports {
timestamp, err := strconv.Atoi(report.Timestamp)
if err != nil {
return nil, err
}
timestamps = append(timestamps, timestamp)
timestampReportMap[timestamp] = &report
}
if len(timestamps) == 0 {
return nil, nil
}
sort.Sort(sort.Reverse(sort.IntSlice(timestamps)))
return timestampReportMap[timestamps[0]], nil
}
// ParseAllValid takes collection of git notes and tries to parse a CI report
// from each one. Any notes that are not valid CI reports get ignored, as we
// expect the git notes to be a heterogenous list, with only some of them
// being valid CI status reports.
func ParseAllValid(notes []repository.Note) []Report {
var reports []Report
for _, note := range notes {
report, err := Parse(note)
if err == nil && report.Version == FormatVersion {
if report.Status == "" || report.Status == StatusSuccess || report.Status == StatusFailure {
reports = append(reports, report)
}
}
}
return reports
}

View file

@ -0,0 +1,85 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ci
import (
"github.com/google/git-appraise/repository"
"testing"
)
const testCINote1 = `{
"Timestamp": "4",
"URL": "www.google.com",
"Status": "success"
}`
const testCINote2 = `{
"Timestamp": "16",
"URL": "www.google.com",
"Status": "failure"
}`
const testCINote3 = `{
"Timestamp": "30",
"URL": "www.google.com",
"Status": "something else"
}`
const testCINote4 = `{
"Timestamp": "28",
"URL": "www.google.com",
"Status": "success"
}`
const testCINote5 = `{
"Timestamp": "27",
"URL": "www.google.com",
"Status": "success"
}`
func TestCIReport(t *testing.T) {
latestReport, err := GetLatestCIReport(ParseAllValid([]repository.Note{
repository.Note(testCINote1),
repository.Note(testCINote2),
}))
if err != nil {
t.Fatal("Failed to properly fetch the latest report", err)
}
expected, err := Parse(repository.Note(testCINote2))
if err != nil {
t.Fatal("Failed to parse the expected report", err)
}
if *latestReport != expected {
t.Fatal("This is not the latest ", latestReport)
}
latestReport, err = GetLatestCIReport(ParseAllValid([]repository.Note{
repository.Note(testCINote1),
repository.Note(testCINote2),
repository.Note(testCINote3),
repository.Note(testCINote4),
}))
if err != nil {
t.Fatal("Failed to properly fetch the latest report", err)
}
expected, err = Parse(repository.Note(testCINote4))
if err != nil {
t.Fatal("Failed to parse the expected report", err)
}
if *latestReport != expected {
t.Fatal("This is not the latest ", latestReport)
}
}

View file

@ -0,0 +1,266 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package comment defines the internal representation of a review comment.
package comment
import (
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/git-appraise/repository"
"github.com/google/git-appraise/review/gpg"
)
// Ref defines the git-notes ref that we expect to contain review comments.
const Ref = "refs/notes/devtools/discuss"
// FormatVersion defines the latest version of the comment format supported by the tool.
const FormatVersion = 0
// ErrInvalidRange inidcates an error during parsing of a user-defined file
// range
var ErrInvalidRange = errors.New("invalid file location range. The required form is StartLine[+StartColumn][:EndLine[+EndColumn]]. The first line in a file is considered to be line 1")
// Range represents the range of text that is under discussion.
type Range struct {
StartLine uint32 `json:"startLine"`
StartColumn uint32 `json:"startColumn,omitempty"`
EndLine uint32 `json:"endLine,omitempty"`
EndColumn uint32 `json:"endColumn,omitempty"`
}
// Location represents the location of a comment within a commit.
type Location struct {
Commit string `json:"commit,omitempty"`
// If the path is omitted, then the comment applies to the entire commit.
Path string `json:"path,omitempty"`
// If the range is omitted, then the location represents an entire file.
Range *Range `json:"range,omitempty"`
}
// Check verifies that this location is valid in the provided
// repository.
func (location *Location) Check(repo repository.Repo) error {
contents, err := repo.Show(location.Commit, location.Path)
if err != nil {
return err
}
lines := strings.Split(contents, "\n")
if location.Range.StartLine > uint32(len(lines)) {
return fmt.Errorf("Line number %d does not exist in file %q",
location.Range.StartLine,
location.Path)
}
if location.Range.StartColumn != 0 &&
location.Range.StartColumn > uint32(len(lines[location.Range.StartLine-1])) {
return fmt.Errorf("Line %d in %q is too short for column %d",
location.Range.StartLine,
location.Path,
location.Range.StartColumn)
}
if location.Range.EndLine != 0 &&
location.Range.EndLine > uint32(len(lines)) {
return fmt.Errorf("End line number %d does not exist in file %q",
location.Range.EndLine,
location.Path)
}
if location.Range.EndColumn != 0 &&
location.Range.EndColumn > uint32(len(lines[location.Range.EndLine-1])) {
return fmt.Errorf("End line %d in %q is too short for column %d",
location.Range.EndLine,
location.Path,
location.Range.EndColumn)
}
return nil
}
// Comment represents a review comment, and can occur in any of the following contexts:
// 1. As a comment on an entire commit.
// 2. As a comment about a specific file in a commit.
// 3. As a comment about a specific line in a commit.
// 4. As a response to another comment.
type Comment struct {
// Timestamp and Author are optimizations that allows us to display comment threads
// without having to run git-blame over the notes object. This is done because
// git-blame will become more and more expensive as the number of code reviews grows.
Timestamp string `json:"timestamp,omitempty"`
Author string `json:"author,omitempty"`
// If original is provided, then the comment is an updated version of another comment.
Original string `json:"original,omitempty"`
// If parent is provided, then the comment is a response to another comment.
Parent string `json:"parent,omitempty"`
// If location is provided, then the comment is specific to that given location.
Location *Location `json:"location,omitempty"`
Description string `json:"description,omitempty"`
// The resolved bit indicates that no further action is needed.
//
// When the parent of the comment is another comment, this means that comment
// has been addressed. Otherwise, the parent is the commit, and this means that the
// change has been accepted. If the resolved bit is unset, then the comment is only an FYI.
Resolved *bool `json:"resolved,omitempty"`
// Version represents the version of the metadata format.
Version int `json:"v,omitempty"`
gpg.Sig
}
// New returns a new comment with the given description message.
//
// The Timestamp and Author fields are automatically filled in with the current time and user.
func New(author string, description string) Comment {
return Comment{
Timestamp: strconv.FormatInt(time.Now().Unix(), 10),
Author: author,
Description: description,
}
}
// Parse parses a review comment from a git note.
func Parse(note repository.Note) (Comment, error) {
bytes := []byte(note)
var comment Comment
err := json.Unmarshal(bytes, &comment)
return comment, err
}
// ParseAllValid takes collection of git notes and tries to parse a review
// comment from each one. Any notes that are not valid review comments get
// ignored, as we expect the git notes to be a heterogenous list, with only
// some of them being review comments.
func ParseAllValid(notes []repository.Note) map[string]Comment {
comments := make(map[string]Comment)
for _, note := range notes {
comment, err := Parse(note)
if err == nil && comment.Version == FormatVersion {
hash, err := comment.Hash()
if err == nil {
comments[hash] = comment
}
}
}
return comments
}
func (comment Comment) serialize() ([]byte, error) {
if len(comment.Timestamp) < 10 {
// To make sure that timestamps from before 2001 appear in the correct
// alphabetical order, we reformat the timestamp to be at least 10 characters
// and zero-padded.
time, err := strconv.ParseInt(comment.Timestamp, 10, 64)
if err == nil {
comment.Timestamp = fmt.Sprintf("%010d", time)
}
// We ignore the other case, as the comment timestamp is not in a format
// we expected, so we should just leave it alone.
}
return json.Marshal(comment)
}
// Write writes a review comment as a JSON-formatted git note.
func (comment Comment) Write() (repository.Note, error) {
bytes, err := comment.serialize()
return repository.Note(bytes), err
}
// Hash returns the SHA1 hash of a review comment.
func (comment Comment) Hash() (string, error) {
bytes, err := comment.serialize()
return fmt.Sprintf("%x", sha1.Sum(bytes)), err
}
// Set implenents flag.Value for the Range type
func (r *Range) Set(s string) error {
var err error
*r = Range{}
if s == "" {
return nil
}
startEndParts := strings.Split(s, ":")
if len(startEndParts) > 2 {
return ErrInvalidRange
}
r.StartLine, r.StartColumn, err = parseRangePart(startEndParts[0])
if err != nil {
return err
}
if len(startEndParts) == 1 {
return nil
}
r.EndLine, r.EndColumn, err = parseRangePart(startEndParts[1])
if err != nil {
return err
}
if r.StartLine > r.EndLine {
return errors.New("start line cannot be greater than end line in range")
}
return nil
}
func parseRangePart(s string) (uint32, uint32, error) {
parts := strings.Split(s, "+")
if len(parts) > 2 {
return 0, 0, ErrInvalidRange
}
line, err := strconv.ParseUint(parts[0], 10, 32)
if err != nil {
return 0, 0, ErrInvalidRange
}
if len(parts) == 1 {
return uint32(line), 0, nil
}
col, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return 0, 0, ErrInvalidRange
}
if line == 0 && col != 0 {
// line 0 represents the entire file
return 0, 0, ErrInvalidRange
}
return uint32(line), uint32(col), nil
}
func (r *Range) String() string {
out := ""
if r.StartLine != 0 {
out = fmt.Sprintf("%d", r.StartLine)
}
if r.StartColumn != 0 {
out = fmt.Sprintf("%s+%d", out, r.StartColumn)
}
if r.EndLine != 0 {
out = fmt.Sprintf("%s:%d", out, r.EndLine)
}
if r.EndColumn != 0 {
out = fmt.Sprintf("%s+%d", out, r.EndColumn)
}
return out
}

View file

@ -0,0 +1,129 @@
// Package gpg provides an interface and an abstraction with which to sign and
// verify review requests and comments.
package gpg
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
)
const placeholder = "gpgsig"
// Sig provides an abstraction around shelling out to GPG to sign the
// content it's given.
type Sig struct {
// Sig holds an object's content's signature.
Sig string `json:"signature,omitempty"`
}
// Signable is an interfaces which provides the pointer to the signable
// object's stringified signature.
//
// This pointer is used by `Sign` and `Verify` to replace its contents with
// `placeholder` or the signature itself for the purposes of signing or
// verifying.
type Signable interface {
Signature() *string
}
// Signature is `Sig`'s implementation of `Signable`. Through this function, an
// object which needs to implement `Signable` need only embed `Sig`
// anonymously. See, e.g., review/request.go.
func (s *Sig) Signature() *string {
return &s.Sig
}
// Sign uses gpg to sign the contents of a request and deposit it into the
// signature key of the request.
func Sign(key string, s Signable) error {
// First we retrieve the pointer and write `placeholder` as its value.
sigPtr := s.Signature()
*sigPtr = placeholder
// Marshal the content and sign it.
content, err := json.Marshal(s)
if err != nil {
return err
}
sig, err := signContent(key, content)
if err != nil {
return err
}
// Write the signature as the new value at the pointer.
*sigPtr = sig.String()
return nil
}
func signContent(key string, content []byte) (*bytes.Buffer,
error) {
var stdout, stderr bytes.Buffer
cmd := exec.Command("gpg", "-u", key, "--detach-sign", "--armor")
cmd.Stdin = bytes.NewReader(content)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return &stdout, err
}
// Verify verifies the signatures on the request and its comments with the
// given key.
func Verify(s Signable) error {
// Retrieve the pointer.
sigPtr := s.Signature()
// Copy its contents.
sig := *sigPtr
// Overwrite the value with the placeholder.
*sigPtr = placeholder
defer func() { *sigPtr = sig }()
// 1. Marshal the content into JSON.
// 2. Write the signature and the content to temp files.
// 3. Use gpg to verify the signature.
content, err := json.Marshal(s)
if err != nil {
return err
}
sigFile, err := ioutil.TempFile("", "sig")
if err != nil {
return err
}
defer os.Remove(sigFile.Name())
_, err = sigFile.Write([]byte(sig))
if err != nil {
return err
}
err = sigFile.Close()
if err != nil {
return err
}
contentFile, err := ioutil.TempFile("", "content")
if err != nil {
return err
}
defer os.Remove(contentFile.Name())
_, err = contentFile.Write(content)
if err != nil {
return err
}
err = contentFile.Close()
if err != nil {
return err
}
var stdout, stderr bytes.Buffer
cmd := exec.Command("gpg", "--verify", sigFile.Name(), contentFile.Name())
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("%s", stderr.String())
}
return nil
}

View file

@ -0,0 +1,104 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package request defines the internal representation of a review request.
package request
import (
"encoding/json"
"strconv"
"time"
"github.com/google/git-appraise/repository"
"github.com/google/git-appraise/review/gpg"
)
// Ref defines the git-notes ref that we expect to contain review requests.
const Ref = "refs/notes/devtools/reviews"
// FormatVersion defines the latest version of the request format supported by the tool.
const FormatVersion = 0
// Request represents an initial request for a code review.
//
// Every field is optional.
type Request struct {
// Timestamp and Requester are optimizations that allows us to display reviews
// without having to run git-blame over the notes object. This is done because
// git-blame will become more and more expensive as the number of reviews grows.
Timestamp string `json:"timestamp,omitempty"`
ReviewRef string `json:"reviewRef,omitempty"`
TargetRef string `json:"targetRef"`
Requester string `json:"requester,omitempty"`
Reviewers []string `json:"reviewers,omitempty"`
Description string `json:"description,omitempty"`
// Version represents the version of the metadata format.
Version int `json:"v,omitempty"`
// BaseCommit stores the commit ID of the target ref at the time the review was requested.
// This is optional, and only used for submitted reviews which were anchored at a merge commit.
// This allows someone viewing that submitted review to find the diff against which the
// code was reviewed.
BaseCommit string `json:"baseCommit,omitempty"`
// Alias stores a post-rebase commit ID for the review. This allows the tool
// to track the history of a review even if the commit history changes.
Alias string `json:"alias,omitempty"`
gpg.Sig
}
// New returns a new request.
//
// The Timestamp and Requester fields are automatically filled in with the current time and user.
func New(requester string, reviewers []string, reviewRef, targetRef, description string) Request {
return Request{
Timestamp: strconv.FormatInt(time.Now().Unix(), 10),
Requester: requester,
Reviewers: reviewers,
ReviewRef: reviewRef,
TargetRef: targetRef,
Description: description,
}
}
// Parse parses a review request from a git note.
func Parse(note repository.Note) (Request, error) {
bytes := []byte(note)
var request Request
err := json.Unmarshal(bytes, &request)
// TODO(ojarjur): If "requester" is not set, then use git-blame to fill it in.
return request, err
}
// ParseAllValid takes collection of git notes and tries to parse a review
// request from each one. Any notes that are not valid review requests get
// ignored, as we expect the git notes to be a heterogenous list, with only
// some of them being review requests.
func ParseAllValid(notes []repository.Note) []Request {
var requests []Request
for _, note := range notes {
request, err := Parse(note)
if err == nil && request.Version == FormatVersion {
requests = append(requests, request)
}
}
return requests
}
// Write writes a review request as a JSON-formatted git note.
func (request *Request) Write() (repository.Note, error) {
bytes, err := json.Marshal(request)
return repository.Note(bytes), err
}

View file

@ -0,0 +1,772 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package review contains the data structures used to represent code reviews.
package review
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"github.com/google/git-appraise/repository"
"github.com/google/git-appraise/review/analyses"
"github.com/google/git-appraise/review/ci"
"github.com/google/git-appraise/review/comment"
"github.com/google/git-appraise/review/gpg"
"github.com/google/git-appraise/review/request"
)
const archiveRef = "refs/devtools/archives/reviews"
// CommentThread represents the tree-based hierarchy of comments.
//
// The Resolved field represents the aggregate status of the entire thread. If
// it is set to false, then it indicates that there is an unaddressed comment
// in the thread. If it is unset, then that means that the root comment is an
// FYI only, and that there are no unaddressed comments. If it is set to true,
// then that means that there are no unaddressed comments, and that the root
// comment has its resolved bit set to true.
type CommentThread struct {
Hash string `json:"hash,omitempty"`
Comment comment.Comment `json:"comment"`
Original *comment.Comment `json:"original,omitempty"`
Edits []*comment.Comment `json:"edits,omitempty"`
Children []CommentThread `json:"children,omitempty"`
Resolved *bool `json:"resolved,omitempty"`
Edited bool `json:"edited,omitempty"`
}
// Summary represents the high-level state of a code review.
//
// This high-level state corresponds to the data that can be quickly read
// directly from the repo, so other methods that need to operate on a lot
// of reviews (such as listing the open reviews) should prefer operating on
// the summary rather than the details.
//
// Review summaries have two status fields which are orthogonal:
// 1. Resolved indicates if a reviewer has accepted or rejected the change.
// 2. Submitted indicates if the change has been incorporated into the target.
type Summary struct {
Repo repository.Repo `json:"-"`
Revision string `json:"revision"`
Request request.Request `json:"request"`
AllRequests []request.Request `json:"-"`
Comments []CommentThread `json:"comments,omitempty"`
Resolved *bool `json:"resolved,omitempty"`
Submitted bool `json:"submitted"`
}
// Review represents the entire state of a code review.
//
// This extends Summary to also include a list of reports for both the
// continuous integration status, and the static analysis runs. Those reports
// correspond to either the current commit in the review ref (for pending
// reviews), or to the last commented-upon commit (for submitted reviews).
type Review struct {
*Summary
Reports []ci.Report `json:"reports,omitempty"`
Analyses []analyses.Report `json:"analyses,omitempty"`
}
type commentsByTimestamp []*comment.Comment
// Interface methods for sorting comment threads by timestamp
func (cs commentsByTimestamp) Len() int { return len(cs) }
func (cs commentsByTimestamp) Swap(i, j int) { cs[i], cs[j] = cs[j], cs[i] }
func (cs commentsByTimestamp) Less(i, j int) bool {
return cs[i].Timestamp < cs[j].Timestamp
}
type byTimestamp []CommentThread
// Interface methods for sorting comment threads by timestamp
func (threads byTimestamp) Len() int { return len(threads) }
func (threads byTimestamp) Swap(i, j int) { threads[i], threads[j] = threads[j], threads[i] }
func (threads byTimestamp) Less(i, j int) bool {
return threads[i].Comment.Timestamp < threads[j].Comment.Timestamp
}
type requestsByTimestamp []request.Request
// Interface methods for sorting review requests by timestamp
func (requests requestsByTimestamp) Len() int { return len(requests) }
func (requests requestsByTimestamp) Swap(i, j int) {
requests[i], requests[j] = requests[j], requests[i]
}
func (requests requestsByTimestamp) Less(i, j int) bool {
return requests[i].Timestamp < requests[j].Timestamp
}
type summariesWithNewestRequestsFirst []Summary
// Interface methods for sorting review summaries in reverse chronological order
func (summaries summariesWithNewestRequestsFirst) Len() int { return len(summaries) }
func (summaries summariesWithNewestRequestsFirst) Swap(i, j int) {
summaries[i], summaries[j] = summaries[j], summaries[i]
}
func (summaries summariesWithNewestRequestsFirst) Less(i, j int) bool {
return summaries[i].Request.Timestamp > summaries[j].Request.Timestamp
}
// updateThreadsStatus calculates the aggregate status of a sequence of comment threads.
//
// The aggregate status is the conjunction of all of the non-nil child statuses.
//
// This has the side-effect of setting the "Resolved" field of all descendant comment threads.
func updateThreadsStatus(threads []CommentThread) *bool {
sort.Stable(byTimestamp(threads))
noUnresolved := true
var result *bool
for i := range threads {
thread := &threads[i]
thread.updateResolvedStatus()
if thread.Resolved != nil {
noUnresolved = noUnresolved && *thread.Resolved
result = &noUnresolved
}
}
return result
}
// updateResolvedStatus calculates the aggregate status of a single comment thread,
// and updates the "Resolved" field of that thread accordingly.
func (thread *CommentThread) updateResolvedStatus() {
resolved := updateThreadsStatus(thread.Children)
if resolved == nil {
thread.Resolved = thread.Comment.Resolved
return
}
if !*resolved {
thread.Resolved = resolved
return
}
if thread.Comment.Resolved == nil || !*thread.Comment.Resolved {
thread.Resolved = nil
return
}
thread.Resolved = resolved
}
// Verify verifies the signature on a comment.
func (thread *CommentThread) Verify() error {
err := gpg.Verify(&thread.Comment)
if err != nil {
hash, _ := thread.Comment.Hash()
return fmt.Errorf("verification of comment [%s] failed: %s", hash, err)
}
for _, child := range thread.Children {
err = child.Verify()
if err != nil {
return err
}
}
return nil
}
// mutableThread is an internal-only data structure used to store partially constructed comment threads.
type mutableThread struct {
Hash string
Comment comment.Comment
Edits []*comment.Comment
Children []*mutableThread
}
// fixMutableThread is a helper method to finalize a mutableThread struct
// (partially constructed comment thread) as a CommentThread struct
// (fully constructed comment thread).
func fixMutableThread(mutableThread *mutableThread) CommentThread {
var children []CommentThread
edited := len(mutableThread.Edits) > 0
for _, mutableChild := range mutableThread.Children {
child := fixMutableThread(mutableChild)
if (!edited) && child.Edited {
edited = true
}
children = append(children, child)
}
comment := &mutableThread.Comment
if len(mutableThread.Edits) > 0 {
sort.Stable(commentsByTimestamp(mutableThread.Edits))
comment = mutableThread.Edits[len(mutableThread.Edits)-1]
}
return CommentThread{
Hash: mutableThread.Hash,
Comment: *comment,
Original: &mutableThread.Comment,
Edits: mutableThread.Edits,
Children: children,
Edited: edited,
}
}
// This function builds the comment thread tree from the log-based list of comments.
//
// Since the comments can be processed in any order, this uses an internal mutable
// data structure, and then converts it to the proper CommentThread structure at the end.
func buildCommentThreads(commentsByHash map[string]comment.Comment) []CommentThread {
threadsByHash := make(map[string]*mutableThread)
for hash, comment := range commentsByHash {
thread, ok := threadsByHash[hash]
if !ok {
thread = &mutableThread{
Hash: hash,
Comment: comment,
}
threadsByHash[hash] = thread
}
}
var rootHashes []string
for hash, thread := range threadsByHash {
if thread.Comment.Original != "" {
original, ok := threadsByHash[thread.Comment.Original]
if ok {
original.Edits = append(original.Edits, &thread.Comment)
}
} else if thread.Comment.Parent == "" {
rootHashes = append(rootHashes, hash)
} else {
parent, ok := threadsByHash[thread.Comment.Parent]
if ok {
parent.Children = append(parent.Children, thread)
}
}
}
var threads []CommentThread
for _, hash := range rootHashes {
threads = append(threads, fixMutableThread(threadsByHash[hash]))
}
return threads
}
// loadComments reads in the log-structured sequence of comments for a review,
// and then builds the corresponding tree-structured comment threads.
func (r *Summary) loadComments(commentNotes []repository.Note) []CommentThread {
commentsByHash := comment.ParseAllValid(commentNotes)
return buildCommentThreads(commentsByHash)
}
func getSummaryFromNotes(repo repository.Repo, revision string, requestNotes, commentNotes []repository.Note) (*Summary, error) {
requests := request.ParseAllValid(requestNotes)
if requests == nil {
return nil, fmt.Errorf("Could not find any review requests for %q", revision)
}
sort.Stable(requestsByTimestamp(requests))
reviewSummary := Summary{
Repo: repo,
Revision: revision,
Request: requests[len(requests)-1],
AllRequests: requests,
}
reviewSummary.Comments = reviewSummary.loadComments(commentNotes)
reviewSummary.Resolved = updateThreadsStatus(reviewSummary.Comments)
return &reviewSummary, nil
}
// GetSummary returns the summary of the code review specified by its revision
// and the references which contain that reviews summary and comments.
//
// If no review request exists, the returned review summary is nil.
func GetSummaryViaRefs(repo repository.Repo, requestRef, commentRef,
revision string) (*Summary, error) {
if err := repo.VerifyCommit(revision); err != nil {
return nil, fmt.Errorf("Could not find a commit named %q", revision)
}
requestNotes := repo.GetNotes(requestRef, revision)
commentNotes := repo.GetNotes(commentRef, revision)
summary, err := getSummaryFromNotes(repo, revision, requestNotes, commentNotes)
if err != nil {
return nil, err
}
currentCommit := revision
if summary.Request.Alias != "" {
currentCommit = summary.Request.Alias
}
if !summary.IsAbandoned() {
submitted, err := repo.IsAncestor(currentCommit, summary.Request.TargetRef)
if err != nil {
return nil, err
}
summary.Submitted = submitted
}
return summary, nil
}
// GetSummary returns the summary of the specified code review.
//
// If no review request exists, the returned review summary is nil.
func GetSummary(repo repository.Repo, revision string) (*Summary, error) {
return GetSummaryViaRefs(repo, request.Ref, comment.Ref, revision)
}
// Details returns the detailed review for the given summary.
func (r *Summary) Details() (*Review, error) {
review := Review{
Summary: r,
}
currentCommit, err := review.GetHeadCommit()
if err == nil {
review.Reports = ci.ParseAllValid(review.Repo.GetNotes(ci.Ref, currentCommit))
review.Analyses = analyses.ParseAllValid(review.Repo.GetNotes(analyses.Ref, currentCommit))
}
return &review, nil
}
// IsAbandoned returns whether or not the given review has been abandoned.
func (r *Summary) IsAbandoned() bool {
return r.Request.TargetRef == ""
}
// IsOpen returns whether or not the given review is still open (neither submitted nor abandoned).
func (r *Summary) IsOpen() bool {
return !r.Submitted && !r.IsAbandoned()
}
// Verify returns whether or not a summary's comments are a) signed, and b)
/// that those signatures are verifiable.
func (r *Summary) Verify() error {
err := gpg.Verify(&r.Request)
if err != nil {
return fmt.Errorf("couldn't verify request targeting: %q: %s",
r.Request.TargetRef, err)
}
for _, thread := range r.Comments {
err := thread.Verify()
if err != nil {
return err
}
}
return nil
}
// Get returns the specified code review.
//
// If no review request exists, the returned review is nil.
func Get(repo repository.Repo, revision string) (*Review, error) {
summary, err := GetSummary(repo, revision)
if err != nil {
return nil, err
}
if summary == nil {
return nil, nil
}
return summary.Details()
}
func getIsSubmittedCheck(repo repository.Repo) func(ref, commit string) bool {
refCommitsMap := make(map[string]map[string]bool)
getRefCommitsMap := func(ref string) map[string]bool {
commitsMap, ok := refCommitsMap[ref]
if ok {
return commitsMap
}
commitsMap = make(map[string]bool)
for _, commit := range repo.ListCommits(ref) {
commitsMap[commit] = true
}
refCommitsMap[ref] = commitsMap
return commitsMap
}
return func(ref, commit string) bool {
return getRefCommitsMap(ref)[commit]
}
}
func unsortedListAll(repo repository.Repo) []Summary {
reviewNotesMap, err := repo.GetAllNotes(request.Ref)
if err != nil {
return nil
}
discussNotesMap, err := repo.GetAllNotes(comment.Ref)
if err != nil {
return nil
}
isSubmittedCheck := getIsSubmittedCheck(repo)
var reviews []Summary
for commit, notes := range reviewNotesMap {
summary, err := getSummaryFromNotes(repo, commit, notes, discussNotesMap[commit])
if err != nil {
continue
}
if !summary.IsAbandoned() {
summary.Submitted = isSubmittedCheck(summary.Request.TargetRef, summary.getStartingCommit())
}
reviews = append(reviews, *summary)
}
return reviews
}
// ListAll returns all reviews stored in the git-notes.
func ListAll(repo repository.Repo) []Summary {
reviews := unsortedListAll(repo)
sort.Stable(summariesWithNewestRequestsFirst(reviews))
return reviews
}
// ListOpen returns all reviews that are not yet incorporated into their target refs.
func ListOpen(repo repository.Repo) []Summary {
var openReviews []Summary
for _, review := range unsortedListAll(repo) {
if review.IsOpen() {
openReviews = append(openReviews, review)
}
}
sort.Stable(summariesWithNewestRequestsFirst(openReviews))
return openReviews
}
// GetCurrent returns the current, open code review.
//
// If there are multiple matching reviews, then an error is returned.
func GetCurrent(repo repository.Repo) (*Review, error) {
reviewRef, err := repo.GetHeadRef()
if err != nil {
return nil, err
}
var matchingReviews []Summary
for _, review := range ListOpen(repo) {
if review.Request.ReviewRef == reviewRef {
matchingReviews = append(matchingReviews, review)
}
}
if matchingReviews == nil {
return nil, nil
}
if len(matchingReviews) != 1 {
return nil, fmt.Errorf("There are %d open reviews for the ref \"%s\"", len(matchingReviews), reviewRef)
}
return matchingReviews[0].Details()
}
// GetBuildStatusMessage returns a string of the current build-and-test status
// of the review, or "unknown" if the build-and-test status cannot be determined.
func (r *Review) GetBuildStatusMessage() string {
statusMessage := "unknown"
ciReport, err := ci.GetLatestCIReport(r.Reports)
if err != nil {
return fmt.Sprintf("unknown: %s", err)
}
if ciReport != nil {
statusMessage = fmt.Sprintf("%s (%q)", ciReport.Status, ciReport.URL)
}
return statusMessage
}
// GetAnalysesNotes returns all of the notes from the most recent static
// analysis run recorded in the git notes.
func (r *Review) GetAnalysesNotes() ([]analyses.Note, error) {
latestAnalyses, err := analyses.GetLatestAnalysesReport(r.Analyses)
if err != nil {
return nil, err
}
if latestAnalyses == nil {
return nil, fmt.Errorf("No analyses available")
}
return latestAnalyses.GetNotes()
}
// GetAnalysesMessage returns a string summarizing the results of the
// most recent static analyses.
func (r *Review) GetAnalysesMessage() string {
latestAnalyses, err := analyses.GetLatestAnalysesReport(r.Analyses)
if err != nil {
return err.Error()
}
if latestAnalyses == nil {
return "No analyses available"
}
status := latestAnalyses.Status
if status != "" && status != analyses.StatusNeedsMoreWork {
return status
}
analysesNotes, err := latestAnalyses.GetNotes()
if err != nil {
return err.Error()
}
if analysesNotes == nil {
return "passed"
}
return fmt.Sprintf("%d warnings\n", len(analysesNotes))
// TODO(ojarjur): Figure out the best place to display the actual notes
}
func prettyPrintJSON(jsonBytes []byte) (string, error) {
var prettyBytes bytes.Buffer
err := json.Indent(&prettyBytes, jsonBytes, "", " ")
if err != nil {
return "", err
}
return prettyBytes.String(), nil
}
// GetJSON returns the pretty printed JSON for a review summary.
func (r *Summary) GetJSON() (string, error) {
jsonBytes, err := json.Marshal(*r)
if err != nil {
return "", err
}
return prettyPrintJSON(jsonBytes)
}
// GetJSON returns the pretty printed JSON for a review.
func (r *Review) GetJSON() (string, error) {
jsonBytes, err := json.Marshal(*r)
if err != nil {
return "", err
}
return prettyPrintJSON(jsonBytes)
}
// findLastCommit returns the later (newest) commit from the union of the provided commit
// and all of the commits that are referenced in the given comment threads.
func (r *Review) findLastCommit(startingCommit, latestCommit string, commentThreads []CommentThread) string {
isLater := func(commit string) bool {
if err := r.Repo.VerifyCommit(commit); err != nil {
return false
}
if t, e := r.Repo.IsAncestor(latestCommit, commit); e == nil && t {
return true
}
if t, e := r.Repo.IsAncestor(startingCommit, commit); e == nil && !t {
return false
}
if t, e := r.Repo.IsAncestor(commit, latestCommit); e == nil && t {
return false
}
ct, err := r.Repo.GetCommitTime(commit)
if err != nil {
return false
}
lt, err := r.Repo.GetCommitTime(latestCommit)
if err != nil {
return true
}
return ct > lt
}
updateLatest := func(commit string) {
if commit == "" {
return
}
if isLater(commit) {
latestCommit = commit
}
}
for _, commentThread := range commentThreads {
comment := commentThread.Comment
if comment.Location != nil {
updateLatest(comment.Location.Commit)
}
updateLatest(r.findLastCommit(startingCommit, latestCommit, commentThread.Children))
}
return latestCommit
}
func (r *Summary) getStartingCommit() string {
if r.Request.Alias != "" {
return r.Request.Alias
}
return r.Revision
}
// GetHeadCommit returns the latest commit in a review.
func (r *Review) GetHeadCommit() (string, error) {
currentCommit := r.getStartingCommit()
if r.Request.ReviewRef == "" {
return currentCommit, nil
}
if r.Submitted {
// The review has already been submitted.
// Go through the list of comments and find the last commented upon commit.
return r.findLastCommit(currentCommit, currentCommit, r.Comments), nil
}
// It is possible that the review ref is no longer an ancestor of the starting
// commit (e.g. if a rebase left us in a detached head), in which case we have to
// find the head commit without using it.
useReviewRef, err := r.Repo.IsAncestor(currentCommit, r.Request.ReviewRef)
if err != nil {
return "", err
}
if useReviewRef {
return r.Repo.ResolveRefCommit(r.Request.ReviewRef)
}
return r.findLastCommit(currentCommit, currentCommit, r.Comments), nil
}
// GetBaseCommit returns the commit against which a review should be compared.
func (r *Review) GetBaseCommit() (string, error) {
if !r.IsOpen() {
if r.Request.BaseCommit != "" {
return r.Request.BaseCommit, nil
}
// This means the review has been submitted, but did not specify a base commit.
// In this case, we have to treat the last parent commit as the base. This is
// usually what we want, since merging a target branch into a feature branch
// results in the previous commit to the feature branch being the first parent,
// and the latest commit to the target branch being the second parent.
return r.Repo.GetLastParent(r.Revision)
}
targetRefHead, err := r.Repo.ResolveRefCommit(r.Request.TargetRef)
if err != nil {
return "", err
}
leftHandSide := targetRefHead
rightHandSide := r.Revision
if r.Request.ReviewRef != "" {
if reviewRefHead, err := r.Repo.ResolveRefCommit(r.Request.ReviewRef); err == nil {
rightHandSide = reviewRefHead
}
}
return r.Repo.MergeBase(leftHandSide, rightHandSide)
}
// ListCommits lists the commits included in a review.
func (r *Review) ListCommits() ([]string, error) {
baseCommit, err := r.GetBaseCommit()
if err != nil {
return nil, err
}
headCommit, err := r.GetHeadCommit()
if err != nil {
return nil, err
}
return r.Repo.ListCommitsBetween(baseCommit, headCommit)
}
// GetDiff returns the diff for a review.
func (r *Review) GetDiff(diffArgs ...string) (string, error) {
var baseCommit, headCommit string
baseCommit, err := r.GetBaseCommit()
if err == nil {
headCommit, err = r.GetHeadCommit()
}
if err == nil {
return r.Repo.Diff(baseCommit, headCommit, diffArgs...)
}
return "", err
}
// AddComment adds the given comment to the review.
func (r *Review) AddComment(c comment.Comment) error {
commentNote, err := c.Write()
if err != nil {
return err
}
r.Repo.AppendNote(comment.Ref, r.Revision, commentNote)
return nil
}
// Rebase performs an interactive rebase of the review onto its target ref.
//
// If the 'archivePrevious' argument is true, then the previous head of the
// review will be added to the 'refs/devtools/archives/reviews' ref prior
// to being rewritten. That ensures the review history is kept from being
// garbage collected.
func (r *Review) Rebase(archivePrevious bool) error {
if archivePrevious {
orig, err := r.GetHeadCommit()
if err != nil {
return err
}
if err := r.Repo.ArchiveRef(orig, archiveRef); err != nil {
return err
}
}
if err := r.Repo.SwitchToRef(r.Request.ReviewRef); err != nil {
return err
}
err := r.Repo.RebaseRef(r.Request.TargetRef)
if err != nil {
return err
}
alias, err := r.Repo.GetCommitHash("HEAD")
if err != nil {
return err
}
r.Request.Alias = alias
newNote, err := r.Request.Write()
if err != nil {
return err
}
return r.Repo.AppendNote(request.Ref, r.Revision, newNote)
}
// RebaseAndSign performs an interactive rebase of the review onto its
// target ref. It signs the result of the rebase as well as (re)signs
// the review request itself.
//
// If the 'archivePrevious' argument is true, then the previous head of the
// review will be added to the 'refs/devtools/archives/reviews' ref prior
// to being rewritten. That ensures the review history is kept from being
// garbage collected.
func (r *Review) RebaseAndSign(archivePrevious bool) error {
if archivePrevious {
orig, err := r.GetHeadCommit()
if err != nil {
return err
}
if err := r.Repo.ArchiveRef(orig, archiveRef); err != nil {
return err
}
}
if err := r.Repo.SwitchToRef(r.Request.ReviewRef); err != nil {
return err
}
err := r.Repo.RebaseAndSignRef(r.Request.TargetRef)
if err != nil {
return err
}
alias, err := r.Repo.GetCommitHash("HEAD")
if err != nil {
return err
}
r.Request.Alias = alias
key, err := r.Repo.GetUserSigningKey()
if err != nil {
return err
}
err = gpg.Sign(key, &r.Request)
if err != nil {
return err
}
newNote, err := r.Request.Write()
if err != nil {
return err
}
return r.Repo.AppendNote(request.Ref, r.Revision, newNote)
}

View file

@ -0,0 +1,870 @@
/*
Copyright 2015 Google Inc. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package review
import (
"github.com/google/git-appraise/repository"
"github.com/google/git-appraise/review/comment"
"github.com/google/git-appraise/review/request"
"sort"
"testing"
)
func TestCommentSorting(t *testing.T) {
sampleComments := []*comment.Comment{
&comment.Comment{
Timestamp: "012400",
Description: "Fourth",
},
&comment.Comment{
Timestamp: "012400",
Description: "Fifth",
},
&comment.Comment{
Timestamp: "012346",
Description: "Second",
},
&comment.Comment{
Timestamp: "012345",
Description: "First",
},
&comment.Comment{
Timestamp: "012347",
Description: "Third",
},
}
sort.Stable(commentsByTimestamp(sampleComments))
descriptions := []string{}
for _, comment := range sampleComments {
descriptions = append(descriptions, comment.Description)
}
if !(descriptions[0] == "First" && descriptions[1] == "Second" && descriptions[2] == "Third" && descriptions[3] == "Fourth" && descriptions[4] == "Fifth") {
t.Fatalf("Comment ordering failed. Got %v", sampleComments)
}
}
func TestThreadSorting(t *testing.T) {
sampleThreads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012400",
Description: "Fourth",
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012400",
Description: "Fifth",
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Description: "Second",
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Description: "First",
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012347",
Description: "Third",
},
},
}
sort.Stable(byTimestamp(sampleThreads))
descriptions := []string{}
for _, thread := range sampleThreads {
descriptions = append(descriptions, thread.Comment.Description)
}
if !(descriptions[0] == "First" && descriptions[1] == "Second" && descriptions[2] == "Third" && descriptions[3] == "Fourth" && descriptions[4] == "Fifth") {
t.Fatalf("Comment thread ordering failed. Got %v", sampleThreads)
}
}
func TestRequestSorting(t *testing.T) {
sampleRequests := []request.Request{
request.Request{
Timestamp: "012400",
Description: "Fourth",
},
request.Request{
Timestamp: "012400",
Description: "Fifth",
},
request.Request{
Timestamp: "012346",
Description: "Second",
},
request.Request{
Timestamp: "012345",
Description: "First",
},
request.Request{
Timestamp: "012347",
Description: "Third",
},
}
sort.Stable(requestsByTimestamp(sampleRequests))
descriptions := []string{}
for _, r := range sampleRequests {
descriptions = append(descriptions, r.Description)
}
if !(descriptions[0] == "First" && descriptions[1] == "Second" && descriptions[2] == "Third" && descriptions[3] == "Fourth" && descriptions[4] == "Fifth") {
t.Fatalf("Review request ordering failed. Got %v", sampleRequests)
}
}
func validateUnresolved(t *testing.T, resolved *bool) {
if resolved != nil {
t.Fatalf("Expected resolved status to be unset, but instead it was %v", *resolved)
}
}
func validateAccepted(t *testing.T, resolved *bool) {
if resolved == nil {
t.Fatal("Expected resolved status to be true, but it was unset")
}
if !*resolved {
t.Fatal("Expected resolved status to be true, but it was false")
}
}
func validateRejected(t *testing.T, resolved *bool) {
if resolved == nil {
t.Fatal("Expected resolved status to be false, but it was unset")
}
if *resolved {
t.Fatal("Expected resolved status to be false, but it was true")
}
}
func (commentThread *CommentThread) validateUnresolved(t *testing.T) {
validateUnresolved(t, commentThread.Resolved)
}
func (commentThread *CommentThread) validateAccepted(t *testing.T) {
validateAccepted(t, commentThread.Resolved)
}
func (commentThread *CommentThread) validateRejected(t *testing.T) {
validateRejected(t, commentThread.Resolved)
}
func TestSimpleAcceptedThreadStatus(t *testing.T) {
resolved := true
simpleThread := CommentThread{
Comment: comment.Comment{
Resolved: &resolved,
},
}
simpleThread.updateResolvedStatus()
simpleThread.validateAccepted(t)
}
func TestSimpleRejectedThreadStatus(t *testing.T) {
resolved := false
simpleThread := CommentThread{
Comment: comment.Comment{
Resolved: &resolved,
},
}
simpleThread.updateResolvedStatus()
simpleThread.validateRejected(t)
}
func TestFYIThenAcceptedThreadStatus(t *testing.T) {
accepted := true
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: nil,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &accepted,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateUnresolved(t)
}
func TestFYIThenFYIThreadStatus(t *testing.T) {
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: nil,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: nil,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateUnresolved(t)
}
func TestFYIThenRejectedThreadStatus(t *testing.T) {
rejected := false
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: nil,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &rejected,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateRejected(t)
}
func TestAcceptedThenAcceptedThreadStatus(t *testing.T) {
accepted := true
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: &accepted,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &accepted,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateAccepted(t)
}
func TestAcceptedThenFYIThreadStatus(t *testing.T) {
accepted := true
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: &accepted,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: nil,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateAccepted(t)
}
func TestAcceptedThenRejectedThreadStatus(t *testing.T) {
accepted := true
rejected := false
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: &accepted,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &rejected,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateRejected(t)
}
func TestRejectedThenAcceptedThreadStatus(t *testing.T) {
accepted := true
rejected := false
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: &rejected,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &accepted,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateUnresolved(t)
}
func TestRejectedThenFYIThreadStatus(t *testing.T) {
rejected := false
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: &rejected,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: nil,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateRejected(t)
}
func TestRejectedThenRejectedThreadStatus(t *testing.T) {
rejected := false
sampleThread := CommentThread{
Comment: comment.Comment{
Resolved: &rejected,
},
Children: []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &rejected,
},
},
},
}
sampleThread.updateResolvedStatus()
sampleThread.validateRejected(t)
}
func TestRejectedThenAcceptedThreadsStatus(t *testing.T) {
accepted := true
rejected := false
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &rejected,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: &accepted,
},
},
}
status := updateThreadsStatus(threads)
validateRejected(t, status)
}
func TestRejectedThenFYIThreadsStatus(t *testing.T) {
rejected := false
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &rejected,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: nil,
},
},
}
status := updateThreadsStatus(threads)
validateRejected(t, status)
}
func TestRejectedThenRejectedThreadsStatus(t *testing.T) {
rejected := false
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &rejected,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: &rejected,
},
},
}
status := updateThreadsStatus(threads)
validateRejected(t, status)
}
func TestAcceptedThenAcceptedThreadsStatus(t *testing.T) {
accepted := true
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &accepted,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: &accepted,
},
},
}
status := updateThreadsStatus(threads)
validateAccepted(t, status)
}
func TestAcceptedThenFYIThreadsStatus(t *testing.T) {
accepted := true
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &accepted,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: nil,
},
},
}
status := updateThreadsStatus(threads)
validateAccepted(t, status)
}
func TestAcceptedThenRejectedThreadsStatus(t *testing.T) {
accepted := true
rejected := false
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: &accepted,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: &rejected,
},
},
}
status := updateThreadsStatus(threads)
validateRejected(t, status)
}
func TestFYIThenAcceptedThreadsStatus(t *testing.T) {
accepted := true
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: nil,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: &accepted,
},
},
}
status := updateThreadsStatus(threads)
validateAccepted(t, status)
}
func TestFYIThenFYIThreadsStatus(t *testing.T) {
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: nil,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: nil,
},
},
}
status := updateThreadsStatus(threads)
validateUnresolved(t, status)
}
func TestFYIThenRejectedThreadsStatus(t *testing.T) {
rejected := false
threads := []CommentThread{
CommentThread{
Comment: comment.Comment{
Timestamp: "012345",
Resolved: nil,
},
},
CommentThread{
Comment: comment.Comment{
Timestamp: "012346",
Resolved: &rejected,
},
},
}
status := updateThreadsStatus(threads)
validateRejected(t, status)
}
func TestBuildCommentThreads(t *testing.T) {
rejected := false
accepted := true
root := comment.Comment{
Timestamp: "012345",
Resolved: nil,
Description: "root",
}
rootHash, err := root.Hash()
if err != nil {
t.Fatal(err)
}
child := comment.Comment{
Timestamp: "012346",
Resolved: nil,
Parent: rootHash,
Description: "child",
}
childHash, err := child.Hash()
updatedChild := comment.Comment{
Timestamp: "012346",
Resolved: &rejected,
Original: childHash,
Description: "updated child",
}
updatedChildHash, err := updatedChild.Hash()
if err != nil {
t.Fatal(err)
}
leaf := comment.Comment{
Timestamp: "012347",
Resolved: &accepted,
Parent: childHash,
Description: "leaf",
}
leafHash, err := leaf.Hash()
if err != nil {
t.Fatal(err)
}
commentsByHash := map[string]comment.Comment{
rootHash: root,
childHash: child,
updatedChildHash: updatedChild,
leafHash: leaf,
}
threads := buildCommentThreads(commentsByHash)
if len(threads) != 1 {
t.Fatalf("Unexpected threads: %v", threads)
}
rootThread := threads[0]
if rootThread.Comment.Description != "root" {
t.Fatalf("Unexpected root thread: %v", rootThread)
}
if !rootThread.Edited {
t.Fatalf("Unexpected root thread edited status: %v", rootThread)
}
if len(rootThread.Children) != 1 {
t.Fatalf("Unexpected root children: %v", rootThread.Children)
}
rootChild := rootThread.Children[0]
if rootChild.Comment.Description != "updated child" {
t.Fatalf("Unexpected updated child: %v", rootChild)
}
if rootChild.Original.Description != "child" {
t.Fatalf("Unexpected original child: %v", rootChild)
}
if len(rootChild.Edits) != 1 {
t.Fatalf("Unexpected child history: %v", rootChild.Edits)
}
if len(rootChild.Children) != 1 {
t.Fatalf("Unexpected leaves: %v", rootChild.Children)
}
threadLeaf := rootChild.Children[0]
if threadLeaf.Comment.Description != "leaf" {
t.Fatalf("Unexpected leaf: %v", threadLeaf)
}
if len(threadLeaf.Children) != 0 {
t.Fatalf("Unexpected leaf children: %v", threadLeaf.Children)
}
if threadLeaf.Edited {
t.Fatalf("Unexpected leaf edited status: %v", threadLeaf)
}
}
func TestGetHeadCommit(t *testing.T) {
repo := repository.NewMockRepoForTest()
submittedSimpleReview, err := Get(repo, repository.TestCommitB)
if err != nil {
t.Fatal(err)
}
submittedSimpleReviewHead, err := submittedSimpleReview.GetHeadCommit()
if err != nil {
t.Fatal("Unable to compute the head commit for a known review of a simple commit: ", err)
}
if submittedSimpleReviewHead != repository.TestCommitB {
t.Fatal("Unexpected head commit computed for a known review of a simple commit.")
}
submittedModifiedReview, err := Get(repo, repository.TestCommitD)
if err != nil {
t.Fatal(err)
}
submittedModifiedReviewHead, err := submittedModifiedReview.GetHeadCommit()
if err != nil {
t.Fatal("Unable to compute the head commit for a known, multi-commit review: ", err)
}
if submittedModifiedReviewHead != repository.TestCommitE {
t.Fatal("Unexpected head commit for a known, multi-commit review.")
}
pendingReview, err := Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
pendingReviewHead, err := pendingReview.GetHeadCommit()
if err != nil {
t.Fatal("Unable to compute the head commit for a known review of a merge commit: ", err)
}
if pendingReviewHead != repository.TestCommitI {
t.Fatal("Unexpected head commit computed for a pending review.")
}
}
func TestGetBaseCommit(t *testing.T) {
repo := repository.NewMockRepoForTest()
submittedSimpleReview, err := Get(repo, repository.TestCommitB)
if err != nil {
t.Fatal(err)
}
submittedSimpleReviewBase, err := submittedSimpleReview.GetBaseCommit()
if err != nil {
t.Fatal("Unable to compute the base commit for a known review of a simple commit: ", err)
}
if submittedSimpleReviewBase != repository.TestCommitA {
t.Fatal("Unexpected base commit computed for a known review of a simple commit.")
}
submittedMergeReview, err := Get(repo, repository.TestCommitD)
if err != nil {
t.Fatal(err)
}
submittedMergeReviewBase, err := submittedMergeReview.GetBaseCommit()
if err != nil {
t.Fatal("Unable to compute the base commit for a known review of a merge commit: ", err)
}
if submittedMergeReviewBase != repository.TestCommitC {
t.Fatal("Unexpected base commit computed for a known review of a merge commit.")
}
pendingReview, err := Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
pendingReviewBase, err := pendingReview.GetBaseCommit()
if err != nil {
t.Fatal("Unable to compute the base commit for a known review of a merge commit: ", err)
}
if pendingReviewBase != repository.TestCommitF {
t.Fatal("Unexpected base commit computed for a pending review.")
}
abandonRequest := pendingReview.Request
abandonRequest.TargetRef = ""
abandonNote, err := abandonRequest.Write()
if err != nil {
t.Fatal(err)
}
if err := repo.AppendNote(request.Ref, repository.TestCommitG, abandonNote); err != nil {
t.Fatal(err)
}
abandonedReview, err := Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
if abandonedReview.IsOpen() {
t.Fatal("Failed to update a review to be abandoned")
}
abandonedReviewBase, err := abandonedReview.GetBaseCommit()
if err != nil {
t.Fatal("Unable to compute the base commit for an abandoned review: ", err)
}
if abandonedReviewBase != repository.TestCommitE {
t.Fatal("Unexpected base commit computed for an abandoned review.")
}
}
func TestGetRequests(t *testing.T) {
repo := repository.NewMockRepoForTest()
pendingReview, err := Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
if len(pendingReview.AllRequests) != 3 || pendingReview.Request.Description != "Final description of G" {
t.Fatal("Unexpected requests for a pending review: ", pendingReview.AllRequests, pendingReview.Request)
}
}
func TestRebase(t *testing.T) {
repo := repository.NewMockRepoForTest()
pendingReview, err := Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
// Rebase the review and then confirm that it has been updated correctly.
if err := pendingReview.Rebase(true); err != nil {
t.Fatal(err)
}
reviewJSON, err := pendingReview.GetJSON()
if err != nil {
t.Fatal(err)
}
headRef, err := repo.GetHeadRef()
if err != nil {
t.Fatal(err)
}
if headRef != pendingReview.Request.ReviewRef {
t.Fatal("Failed to switch to the review ref during a rebase")
}
isAncestor, err := repo.IsAncestor(pendingReview.Revision, archiveRef)
if err != nil {
t.Fatal(err)
}
if !isAncestor {
t.Fatalf("Commit %q is not archived", pendingReview.Revision)
}
reviewCommit, err := repo.GetCommitHash(pendingReview.Request.ReviewRef)
if err != nil {
t.Fatal(err)
}
reviewAlias := pendingReview.Request.Alias
if reviewAlias == "" || reviewAlias == pendingReview.Revision || reviewCommit != reviewAlias {
t.Fatalf("Failed to set the review alias: %q", reviewJSON)
}
// Submit the review.
if err := repo.SwitchToRef(pendingReview.Request.TargetRef); err != nil {
t.Fatal(err)
}
if err := repo.MergeRef(pendingReview.Request.ReviewRef, true); err != nil {
t.Fatal(err)
}
// Reread the review and confirm that it has been submitted.
submittedReview, err := Get(repo, pendingReview.Revision)
if err != nil {
t.Fatal(err)
}
submittedReviewJSON, err := submittedReview.GetJSON()
if err != nil {
t.Fatal(err)
}
if !submittedReview.Submitted {
t.Fatalf("Failed to submit the review: %q", submittedReviewJSON)
}
}
func TestRebaseDetachedHead(t *testing.T) {
repo := repository.NewMockRepoForTest()
pendingReview, err := Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
// Switch the review to having a review ref that is not a branch.
pendingReview.Request.ReviewRef = repository.TestAlternateReviewRef
newNote, err := pendingReview.Request.Write()
if err != nil {
t.Fatal(err)
}
if err := repo.AppendNote(request.Ref, pendingReview.Revision, newNote); err != nil {
t.Fatal(err)
}
pendingReview, err = Get(repo, repository.TestCommitG)
if err != nil {
t.Fatal(err)
}
// Rebase the review and then confirm that it has been updated correctly.
if err := pendingReview.Rebase(true); err != nil {
t.Fatal(err)
}
headRef, err := repo.GetHeadRef()
if err != nil {
t.Fatal(err)
}
if headRef != pendingReview.Request.Alias {
t.Fatal("Failed to switch to a detached head during a rebase")
}
isAncestor, err := repo.IsAncestor(pendingReview.Revision, archiveRef)
if err != nil {
t.Fatal(err)
}
if !isAncestor {
t.Fatalf("Commit %q is not archived", pendingReview.Revision)
}
// Submit the review.
if err := repo.SwitchToRef(pendingReview.Request.TargetRef); err != nil {
t.Fatal(err)
}
reviewHead, err := pendingReview.GetHeadCommit()
if err != nil {
t.Fatal(err)
}
if err := repo.MergeRef(reviewHead, true); err != nil {
t.Fatal(err)
}
// Reread the review and confirm that it has been submitted.
submittedReview, err := Get(repo, pendingReview.Revision)
if err != nil {
t.Fatal(err)
}
submittedReviewJSON, err := submittedReview.GetJSON()
if err != nil {
t.Fatal(err)
}
if !submittedReview.Submitted {
t.Fatalf("Failed to submit the review: %q", submittedReviewJSON)
}
}

View file

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

View file

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

View file

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

View file

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