blob: b293130c8dd4a2745391fe874a82c324dd64cb1c [file] [log] [blame]
// Copyright 2021 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// Package gerrit provides helpers for obtaining information from Tint's gerrit instance
package gerrit
import (
"context"
"flag"
"fmt"
"log"
"net/url"
"strconv"
"strings"
"dawn.googlesource.com/dawn/tools/src/container"
"github.com/andygrunwald/go-gerrit"
"go.chromium.org/luci/auth"
)
// Gerrit is the interface to gerrit
type Gerrit struct {
client *gerrit.Client
authenticated bool
}
// Patchset refers to a single gerrit patchset
type Patchset struct {
// Gerrit host
Host string
// Gerrit project
Project string
// Change ID
Change int
// Patchset ID
Patchset int
}
// ChangeInfo is an alias to gerrit.ChangeInfo
type ChangeInfo = gerrit.ChangeInfo
// LatestPatchset returns the latest Patchset from the ChangeInfo
func LatestPatchset(change *ChangeInfo) Patchset {
u, _ := url.Parse(change.URL)
ps := Patchset{
Host: u.Host,
Project: change.Project,
Change: change.Number,
Patchset: change.Revisions[change.CurrentRevision].Number,
}
return ps
}
// RegisterFlags registers the command line flags to populate p
func (p *Patchset) RegisterFlags(defaultHost, defaultProject string) {
flag.StringVar(&p.Host, "host", defaultHost, "gerrit host")
flag.StringVar(&p.Project, "project", defaultProject, "gerrit project")
flag.IntVar(&p.Change, "cl", 0, "gerrit change id")
flag.IntVar(&p.Patchset, "ps", 0, "gerrit patchset id")
}
// RefsChanges returns the gerrit 'refs/changes/X/Y/Z' string for the patchset
func (p Patchset) RefsChanges() string {
// https://gerrit-review.googlesource.com/Documentation/intro-user.html
// A change ref has the format refs/changes/X/Y/Z where X is the last two
// digits of the change number, Y is the entire change number, and Z is the
// patch set. For example, if the change number is 263270, the ref would be
// refs/changes/70/263270/2 for the second patch set.
shortChange := fmt.Sprintf("%.2v", p.Change)
shortChange = shortChange[len(shortChange)-2:]
return fmt.Sprintf("refs/changes/%v/%v/%v", shortChange, p.Change, p.Patchset)
}
// New returns a new Gerrit instance. If credentials are not provided, then
// New() will automatically attempt to load them from the gitcookies file.
func New(ctx context.Context, opts auth.Options, url string) (*Gerrit, error) {
http, err := auth.NewAuthenticator(ctx, auth.InteractiveLogin, opts).Client()
if err != nil {
return nil, fmt.Errorf("couldn't create gerrit client: %w", err)
}
client, err := gerrit.NewClient(url, http)
if err != nil {
return nil, fmt.Errorf("couldn't create gerrit client: %w", err)
}
return &Gerrit{client, true}, nil
}
// QueryExtraData holds extra data to query for with QueryChangesWith()
type QueryExtraData struct {
Labels bool
Messages bool
CurrentRevision bool
DetailedAccounts bool
Submittable bool
}
// QueryChanges returns the changes that match the given query strings.
// See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
func (g *Gerrit) QueryChangesWith(extras QueryExtraData, queries ...string) (changes []gerrit.ChangeInfo, query string, err error) {
changes = []gerrit.ChangeInfo{}
query = strings.Join(queries, "+")
changeOpts := gerrit.ChangeOptions{}
if extras.Labels {
changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "LABELS")
}
if extras.Messages {
changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "MESSAGES")
}
if extras.CurrentRevision {
changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "CURRENT_REVISION")
}
if extras.DetailedAccounts {
changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "DETAILED_ACCOUNTS")
}
if extras.Submittable {
changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "SUBMITTABLE")
}
for {
batch, _, err := g.client.Changes.QueryChanges(&gerrit.QueryChangeOptions{
QueryOptions: gerrit.QueryOptions{Query: []string{query}},
Skip: len(changes),
ChangeOptions: changeOpts,
})
if err != nil {
return nil, "", err
}
changes = append(changes, *batch...)
if len(*batch) == 0 || !(*batch)[len(*batch)-1].MoreChanges {
break
}
}
return changes, query, nil
}
// QueryChanges returns the changes that match the given query strings.
// See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
func (g *Gerrit) QueryChanges(queries ...string) (changes []gerrit.ChangeInfo, query string, err error) {
return g.QueryChangesWith(QueryExtraData{}, queries...)
}
// ChangesSubmittedTogether returns the changes that want to be submitted together
// See: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together
func (g *Gerrit) ChangesSubmittedTogether(changeID string) (changes []gerrit.ChangeInfo, err error) {
info, _, err := g.client.Changes.ChangesSubmittedTogether(changeID)
if err != nil {
return nil, err
}
return *info, nil
}
func (g *Gerrit) AddLabel(changeID, revisionID, message, label string, value int) error {
_, _, err := g.client.Changes.SetReview(changeID, revisionID, &gerrit.ReviewInput{
Message: message,
Labels: map[string]string{label: fmt.Sprint(value)},
})
if err != nil {
return err
}
return nil
}
// Abandon abandons the change with the given changeID.
func (g *Gerrit) Abandon(changeID string) error {
_, _, err := g.client.Changes.AbandonChange(changeID, &gerrit.AbandonInput{})
if err != nil {
return err
}
return nil
}
// CreateChange creates a new change in the given project and branch, with the
// given subject. If wip is true, then the change is constructed as
// Work-In-Progress.
func (g *Gerrit) CreateChange(project, branch, subject string, wip bool) (*ChangeInfo, error) {
change, _, err := g.client.Changes.CreateChange(&gerrit.ChangeInput{
Project: project,
Branch: branch,
Subject: subject,
WorkInProgress: wip,
})
if err != nil {
return nil, err
}
if change.URL == "" {
base := g.client.BaseURL()
change.URL = fmt.Sprintf("%vc/%v/+/%v", base.String(), change.Project, change.Number)
}
return change, nil
}
// EditFiles replaces the content of the files in the given change. It deletes deletedFiles.
// If newCommitMsg is not an empty string, then the commit message is replaced
// with the string value.
func (g *Gerrit) EditFiles(changeID, newCommitMsg string, files map[string]string, deletedFiles []string) (Patchset, error) {
if newCommitMsg != "" {
resp, err := g.client.Changes.ChangeCommitMessageInChangeEdit(changeID, &gerrit.ChangeEditMessageInput{
Message: newCommitMsg,
})
if err != nil && resp.StatusCode != 409 { // 409 no changes were made
return Patchset{}, err
}
}
for path, content := range files {
resp, err := g.client.Changes.ChangeFileContentInChangeEdit(changeID, path, content)
if err != nil && resp.StatusCode != 409 { // 409 no changes were made
return Patchset{}, err
}
}
for _, path := range deletedFiles {
resp, err := g.client.Changes.DeleteFileInChangeEdit(changeID, path)
if err != nil && resp.StatusCode != 409 { // 409 no changes were made
return Patchset{}, err
}
}
resp, err := g.client.Changes.PublishChangeEdit(changeID, "NONE")
if err != nil && resp.StatusCode != 409 { // 409 no changes were made
return Patchset{}, err
}
return g.LatestPatchset(changeID)
}
// LatestPatchset returns the latest patchset for the change.
func (g *Gerrit) LatestPatchset(changeID string) (Patchset, error) {
change, _, err := g.client.Changes.GetChange(changeID, &gerrit.ChangeOptions{
AdditionalFields: []string{"CURRENT_REVISION"},
})
if err != nil {
return Patchset{}, err
}
ps := Patchset{
Host: g.client.BaseURL().Host,
Project: change.Project,
Change: change.Number,
Patchset: change.Revisions[change.CurrentRevision].Number,
}
return ps, nil
}
// AddHashtags adds the given hashtags to the change
func (g *Gerrit) AddHashtags(changeID string, tags container.Set[string]) error {
_, resp, err := g.client.Changes.SetHashtags(changeID, &gerrit.HashtagsInput{
Add: tags.List(),
})
if err != nil && resp.StatusCode != 409 { // 409: already ready
return err
}
return nil
}
// CommentSide is an enumerator for specifying which side code-comments should
// be shown.
type CommentSide int
const (
// Left is used to specify that code comments should appear on the parent change
Left CommentSide = iota
// Right is used to specify that code comments should appear on the new change
Right
)
// FileComment describes a single comment on a file
type FileComment struct {
Path string // The file path
Side CommentSide // Which side the comment should appear
Line int // The 1-based line number for the comment
Message string // The comment message
}
// Comment posts a review comment on the given patchset.
// If comments is an optional list of file-comments to include in the comment.
func (g *Gerrit) Comment(ps Patchset, msg string, comments []FileComment) error {
input := &gerrit.ReviewInput{
Message: msg,
}
if len(comments) > 0 {
input.Comments = map[string][]gerrit.CommentInput{}
for _, c := range comments {
ci := gerrit.CommentInput{
Line: c.Line,
// Updated: &gerrit.Timestamp{Time: time.Now()},
Message: c.Message,
}
if c.Side == Left {
ci.Side = "PARENT"
} else {
ci.Side = "REVISION"
}
input.Comments[c.Path] = append(input.Comments[c.Path], ci)
}
}
_, _, err := g.client.Changes.SetReview(strconv.Itoa(ps.Change), strconv.Itoa(ps.Patchset), input)
if err != nil {
return err
}
return nil
}
// SetReadyForReview marks the change as ready for review.
func (g *Gerrit) SetReadyForReview(changeID, message, reviewer string) error {
resp, err := g.client.Changes.SetReadyForReview(changeID, &gerrit.ReadyForReviewInput{
Message: message,
})
if err != nil && resp.StatusCode != 409 { // 409: already ready
return err
}
if reviewer != "" {
log.Printf("Got reviewer %s", reviewer)
_, resp, err = g.client.Changes.AddReviewer(changeID, &gerrit.ReviewerInput{
Reviewer: reviewer,
})
if err != nil && resp.StatusCode != 409 { // 409: already ready
return err
}
}
return nil
}