blob: 29a56233954cc6cd5abd77a752b91c8e80b0cace [file] [log] [blame]
// Copyright 2021 The Tint Authors.
//
// 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 gerrit provides helpers for obtaining information from Tint's gerrit instance
package gerrit
import (
"flag"
"fmt"
"io/ioutil"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"github.com/andygrunwald/go-gerrit"
)
// Gerrit is the interface to gerrit
type Gerrit struct {
client *gerrit.Client
authenticated bool
}
// Credentials holds the user name and password used to access Gerrit.
type Credentials struct {
Username string
Password string
}
// 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
// LatestPatchest returns the latest Patchset from the ChangeInfo
func LatestPatchest(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")
}
// LoadCredentials attempts to load the gerrit credentials for the given gerrit
// URL from the git cookies file. Returns an empty Credentials on failure.
func LoadCredentials(url string) Credentials {
cookiesFile := os.Getenv("HOME") + "/.gitcookies"
if cookies, err := ioutil.ReadFile(cookiesFile); err == nil {
url := strings.TrimPrefix(url, "https://")
re := regexp.MustCompile(url + `\s+(?:FALSE|TRUE)[\s/]+(?:FALSE|TRUE)\s+[0-9]+\s+.\s+(.*)=(.*)`)
match := re.FindStringSubmatch(string(cookies))
if len(match) == 3 {
return Credentials{match[1], match[2]}
}
}
return Credentials{}
}
// 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(url string, cred Credentials) (*Gerrit, error) {
client, err := gerrit.NewClient(url, nil)
if err != nil {
return nil, fmt.Errorf("couldn't create gerrit client: %w", err)
}
if cred.Username == "" {
cred = LoadCredentials(url)
}
if cred.Username != "" {
client.Authentication.SetBasicAuth(cred.Username, cred.Password)
}
return &Gerrit{client, cred.Username != ""}, 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(querys ...string) (changes []gerrit.ChangeInfo, query string, err error) {
changes = []gerrit.ChangeInfo{}
query = strings.Join(querys, "+")
for {
batch, _, err := g.client.Changes.QueryChanges(&gerrit.QueryChangeOptions{
QueryOptions: gerrit.QueryOptions{Query: []string{query}},
Skip: len(changes),
})
if err != nil {
return nil, "", g.maybeWrapError(err)
}
changes = append(changes, *batch...)
if len(*batch) == 0 || !(*batch)[len(*batch)-1].MoreChanges {
break
}
}
return changes, query, 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 g.maybeWrapError(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, g.maybeWrapError(err)
}
return change, nil
}
// EditFiles replaces the content of the files in the given change.
// 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) (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{}, g.maybeWrapError(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{}, g.maybeWrapError(err)
}
}
resp, err := g.client.Changes.PublishChangeEdit(changeID, "NONE")
if err != nil && resp.StatusCode != 409 { // 409 no changes were made
return Patchset{}, g.maybeWrapError(err)
}
return g.LatestPatchest(changeID)
}
// LatestPatchest returns the latest patchset for the change.
func (g *Gerrit) LatestPatchest(changeID string) (Patchset, error) {
change, _, err := g.client.Changes.GetChange(changeID, &gerrit.ChangeOptions{
AdditionalFields: []string{"CURRENT_REVISION"},
})
if err != nil {
return Patchset{}, g.maybeWrapError(err)
}
ps := Patchset{
Host: g.client.BaseURL().Host,
Project: change.Project,
Change: change.Number,
Patchset: change.Revisions[change.CurrentRevision].Number,
}
return ps, nil
}
// Comment posts a review comment on the given patchset.
func (g *Gerrit) Comment(ps Patchset, msg string) error {
_, _, err := g.client.Changes.SetReview(
strconv.Itoa(ps.Change),
strconv.Itoa(ps.Patchset),
&gerrit.ReviewInput{
Message: msg,
})
if err != nil {
return g.maybeWrapError(err)
}
return nil
}
func (g *Gerrit) maybeWrapError(err error) error {
if err != nil && !g.authenticated {
return fmt.Errorf(`query failed, possibly because of authentication.
See https://dawn-review.googlesource.com/new-password for obtaining a username
and password which can be provided with --gerrit-user and --gerrit-pass.
Note: This tool will scan ~/.gitcookies for credentials.
%w`, err)
}
return err
}