blob: 623cba410f7e18a3651704caca5436f388e434d5 [file] [log] [blame]
package gerrit
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os/user"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/golang/groupcache/lru"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/buildbucket"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
)
var (
ErrCookiesMissing = errors.New("Cannot make authenticated post calls without a valid .gitcookies file")
)
const (
TIME_FORMAT = "2006-01-02 15:04:05.999999"
GERRIT_CHROMIUM_URL = "https://chromium-review.googlesource.com"
GERRIT_SKIA_URL = "https://skia-review.googlesource.com"
MAX_GERRIT_LIMIT = 500
AUTH_SCOPE = auth.SCOPE_GERRIT
CHANGE_STATUS_ABANDONED = "ABANDONED"
CHANGE_STATUS_DRAFT = "DRAFT"
CHANGE_STATUS_MERGED = "MERGED"
CHANGE_STATUS_NEW = "NEW"
// Gerrit labels.
CODEREVIEW_LABEL = "Code-Review"
CODEREVIEW_LABEL_DISAPPROVE = -1
CODEREVIEW_LABEL_NONE = 0
CODEREVIEW_LABEL_APPROVE = 1
// Chromium specific labels.
COMMITQUEUE_LABEL = "Commit-Queue"
COMMITQUEUE_LABEL_NONE = 0
COMMITQUEUE_LABEL_DRY_RUN = 1
COMMITQUEUE_LABEL_SUBMIT = 2
// Android specific labels.
AUTOSUBMIT_LABEL = "Autosubmit"
AUTOSUBMIT_LABEL_NONE = 0
AUTOSUBMIT_LABEL_SUBMIT = 1
PRESUBMIT_READY_LABEL = "Presubmit-Ready"
PRESUBMIT_VERIFIED_LABEL = "Presubmit-Verified"
PRESUBMIT_VERIFIED_LABEL_REJECTED = -1
)
// ChangeInfo contains information about a Gerrit issue.
type ChangeInfo struct {
Created time.Time `json:"-"`
CreatedString string `json:"created"`
Updated time.Time `json:"-"`
UpdatedString string `json:"updated"`
Submitted time.Time `json:"-"`
SubmittedString string `json:"submitted"`
Project string `json:"project"`
ChangeId string `json:"change_id"`
Subject string `json:"subject"`
Branch string `json:"branch"`
Committed bool `json:"committed"`
Revisions map[string]*Revision `json:"revisions"`
Patchsets []*Revision `json:"-"`
MoreChanges bool `json:"_more_changes"`
Issue int64 `json:"_number"`
Labels map[string]*LabelEntry `json:"labels"`
Owner *Owner `json:"owner"`
Status string `json:"status"`
}
// IsClosed returns true iff the issue corresponding to the ChangeInfo is
// abandoned or merged.
func (c ChangeInfo) IsClosed() bool {
return (c.Status == CHANGE_STATUS_ABANDONED ||
c.Status == CHANGE_STATUS_MERGED)
}
// Owner gathers the owner information of a ChangeInfo instance. Some fields ommitted.
type Owner struct {
Email string `json:"email"`
}
type LabelEntry struct {
All []*LabelDetail
Values map[string]string
DefaultValue int
}
type LabelDetail struct {
Name string
Email string
Date string
Value int
}
// Revision is the information associated with a patchset in Gerrit.
type Revision struct {
ID string `json:"-"`
Number int64 `json:"_number"`
CreatedString string `json:"created"`
Created time.Time `json:"-"`
}
type GerritInterface interface {
TurnOnAuthenticatedGets()
Url(int64) string
GetUserEmail() (string, error)
GetRepoUrl() string
ExtractIssue(string) (string, bool)
GetIssueProperties(int64) (*ChangeInfo, error)
GetPatch(int64, string) (string, error)
SetReview(*ChangeInfo, string, map[string]interface{}) error
AddComment(*ChangeInfo, string) error
SendToDryRun(*ChangeInfo, string) error
SendToCQ(*ChangeInfo, string) error
RemoveFromCQ(*ChangeInfo, string) error
Approve(*ChangeInfo, string) error
NoScore(*ChangeInfo, string) error
DisApprove(*ChangeInfo, string) error
Abandon(*ChangeInfo, string) error
SetTopic(string, int64) error
Search(int, ...*SearchTerm) ([]*ChangeInfo, error)
GetTrybotResults(int64, int64) ([]*buildbucket.Build, error)
}
// Gerrit is an object used for iteracting with the issue tracker.
type Gerrit struct {
client *http.Client
buildbucketClient *buildbucket.Client
gitCookiesPath string
url string
useAuthenticatedGets bool
}
// NewGerrit returns a new Gerrit instance. If gitCookiesPath is empty the
// instance will be in read-only mode and only return information available to
// anonymous users.
func NewGerrit(url, gitCookiesPath string, client *http.Client) (*Gerrit, error) {
url = strings.TrimRight(url, "/")
if client == nil {
client = httputils.NewTimeoutClient()
}
return &Gerrit{
url: url,
client: client,
buildbucketClient: buildbucket.NewClient(client),
gitCookiesPath: gitCookiesPath,
}, nil
}
// DefaultGitCookiesPath returns the default cookie file. The return value
// can be used as the input to NewGerrit. If it cannot be retrieved an
// error will be logged and the empty string is returned.
func DefaultGitCookiesPath() string {
usr, err := user.Current()
if err != nil {
sklog.Errorf("Unable to retrieve default git cookies path")
return ""
}
return filepath.Join(usr.HomeDir, ".gitcookies")
}
// getCredentials returns the parsed contents of .gitCookies.
// This logic has been borrowed from
// https://cs.chromium.org/chromium/tools/depot_tools/gerrit_util.py?l=143
func getCredentials(gitCookiesPath string) (map[string]string, error) {
// Set empty cookies if no path was given and issue a warning.
if gitCookiesPath == "" {
sklog.Infof("Gerrit client initialized in read-only mode. ")
return map[string]string{}, nil
}
gitCookies := map[string]string{}
dat, err := ioutil.ReadFile(gitCookiesPath)
if err != nil {
return nil, err
}
contents := string(dat)
for _, line := range strings.Split(contents, "\n") {
if strings.HasPrefix(line, "#") || line == "" {
continue
}
tokens := strings.Split(line, "\t")
domain, xpath, key, value := tokens[0], tokens[2], tokens[5], tokens[6]
if xpath == "/" && key == "o" {
gitCookies[domain] = value
}
}
return gitCookies, nil
}
func parseTime(t string) time.Time {
parsed, _ := time.Parse(TIME_FORMAT, t)
return parsed
}
// TurnOnAuthenticatedGets makes all GET requests contain authentication
// cookies. By default only POST requests are automatically authenticated.
func (g *Gerrit) TurnOnAuthenticatedGets() {
g.useAuthenticatedGets = true
}
// Url returns the url of the Gerrit issue identified by issueID or the
// base URL of the Gerrit instance if issueID is 0.
func (g *Gerrit) Url(issueID int64) string {
if issueID == 0 {
return g.url
}
return fmt.Sprintf("%s/c/%d", g.url, issueID)
}
type AccountDetails struct {
AccountId int64 `json:"_account_id"`
Name string `json:"name"`
Email string `json:"email"`
UserName string `json:"username"`
}
// GetUserEmail returns the Gerrit user's email address.
func (g *Gerrit) GetUserEmail() (string, error) {
g.TurnOnAuthenticatedGets()
url := "/accounts/self/detail"
var account AccountDetails
if err := g.get(url, &account); err != nil {
return "", fmt.Errorf("Failed to retrieve user: %s", err)
}
return account.Email, nil
}
// GetRepoUrl returns the url of the Googlesource repo.
func (g *Gerrit) GetRepoUrl() string {
return strings.Replace(g.url, "-review", "", 1)
}
// extractReg is the regular expression used by ExtractIssue.
var extractReg = regexp.MustCompile("^/c/([0-9]+)$")
// ExtractIssue returns the issue id as a string given the issue URL.
// The second return value is true if the issueURL matches the current Gerrit
// instance. If it is false the first return value should be ignored.
func (g *Gerrit) ExtractIssue(issueURL string) (string, bool) {
if !strings.HasPrefix(issueURL, g.url) {
return "", false
}
match := extractReg.FindStringSubmatch(strings.TrimRight(issueURL[len(g.url):], "/"))
if len(match) != 2 {
return "", false
}
return match[1], true
}
// GetIssueProperties returns a fully filled-in ChangeInfo object, as opposed to
// the partial data returned by Gerrit's search endpoint.
func (g *Gerrit) GetIssueProperties(issue int64) (*ChangeInfo, error) {
url := fmt.Sprintf("/changes/%d/detail?o=ALL_REVISIONS", issue)
fullIssue := &ChangeInfo{}
if err := g.get(url, fullIssue); err != nil {
return nil, fmt.Errorf("Failed to load details for issue %d: %v", issue, err)
}
// Set created, updated and submitted timestamps. Also set the committed flag.
fullIssue.Created = parseTime(fullIssue.CreatedString)
fullIssue.Updated = parseTime(fullIssue.UpdatedString)
if fullIssue.SubmittedString != "" {
fullIssue.Submitted = parseTime(fullIssue.SubmittedString)
fullIssue.Committed = true
}
// Make patchset objects with the revision IDs and created timestamps.
patchsets := make([]*Revision, 0, len(fullIssue.Revisions))
for id, r := range fullIssue.Revisions {
// Fill in the missing fields.
r.ID = id
r.Created = parseTime(r.CreatedString)
patchsets = append(patchsets, r)
}
sort.Sort(revisionSlice(patchsets))
fullIssue.Patchsets = patchsets
return fullIssue, nil
}
// GetPatchsetIDs is a convenience function that returns the sorted list of patchset IDs.
func (c *ChangeInfo) GetPatchsetIDs() []int64 {
ret := make([]int64, len(c.Patchsets))
for idx, patchSet := range c.Patchsets {
ret[idx] = patchSet.Number
}
return ret
}
// GetPatch returns the formatted patch for one revision. Documentation is here:
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-patch
func (g *Gerrit) GetPatch(issue int64, revision string) (string, error) {
url := fmt.Sprintf("%s/changes/%d/revisions/%s/patch", g.url, issue, revision)
resp, err := g.client.Get(url)
if err != nil {
return "", fmt.Errorf("Failed to GET %s: %s", url, err)
}
if resp.StatusCode == 404 {
return "", fmt.Errorf("Issue not found: %s", url)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("Error retrieving %s: %d %s", url, resp.StatusCode, resp.Status)
}
defer util.Close(resp.Body)
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("Could not read response body: %s", err)
}
data, err := base64.StdEncoding.DecodeString(string(body))
if err != nil {
return "", fmt.Errorf("Could not base64 decode response body: %s", err)
}
// Extract out only the patch.
tokens := strings.SplitN(string(data), "---", 2)
if len(tokens) != 2 {
return "", fmt.Errorf("Gerrit patch response was invalid: %s", string(data))
}
patch := tokens[1]
return patch, nil
}
// setReview calls the Set Review endpoint of the Gerrit API to add messages and/or set labels for
// the latest patchset.
// API documentation: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-review
func (g *Gerrit) SetReview(issue *ChangeInfo, message string, labels map[string]interface{}) error {
postData := map[string]interface{}{
"message": message,
"labels": labels,
}
latestPatchset := issue.Patchsets[len(issue.Patchsets)-1]
return g.post(fmt.Sprintf("/a/changes/%s/revisions/%s/review", issue.ChangeId, latestPatchset.ID), postData)
}
// AddComment adds a message to the issue.
func (g *Gerrit) AddComment(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{})
}
// Utility methods for interacting with the COMMITQUEUE_LABEL.
func (g *Gerrit) SendToDryRun(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{COMMITQUEUE_LABEL: COMMITQUEUE_LABEL_DRY_RUN})
}
func (g *Gerrit) SendToCQ(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{COMMITQUEUE_LABEL: COMMITQUEUE_LABEL_SUBMIT})
}
func (g *Gerrit) RemoveFromCQ(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{COMMITQUEUE_LABEL: COMMITQUEUE_LABEL_NONE})
}
// Utility methods for interacting with the CODEREVIEW_LABEL.
func (g *Gerrit) Approve(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{CODEREVIEW_LABEL: CODEREVIEW_LABEL_APPROVE})
}
func (g *Gerrit) NoScore(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{CODEREVIEW_LABEL: CODEREVIEW_LABEL_NONE})
}
func (g *Gerrit) DisApprove(issue *ChangeInfo, message string) error {
return g.SetReview(issue, message, map[string]interface{}{CODEREVIEW_LABEL: CODEREVIEW_LABEL_DISAPPROVE})
}
// Abandon abandons the issue with the given message.
func (g *Gerrit) Abandon(issue *ChangeInfo, message string) error {
postData := map[string]interface{}{
"message": message,
}
return g.post(fmt.Sprintf("/a/changes/%s/abandon", issue.ChangeId), postData)
}
func (g *Gerrit) addAuthenticationCookie(req *http.Request) error {
u, err := url.Parse(g.url)
if err != nil {
return err
}
auth := ""
cookies, err := getCredentials(g.gitCookiesPath)
if err != nil {
return err
}
for d, a := range cookies {
if util.CookieDomainMatch(u.Host, d) {
auth = a
cookie := http.Cookie{Name: "o", Value: a}
req.AddCookie(&cookie)
break
}
}
if auth == "" {
return ErrCookiesMissing
}
return nil
}
func (g *Gerrit) get(suburl string, rv interface{}) error {
getURL := g.url + suburl
if g.useAuthenticatedGets {
getURL = g.url + "/a" + suburl
}
req, err := http.NewRequest("GET", getURL, nil)
if err != nil {
return err
}
if g.useAuthenticatedGets {
if err := g.addAuthenticationCookie(req); err != nil {
return err
}
}
resp, err := g.client.Do(req)
if err != nil {
return fmt.Errorf("Failed to GET %s: %s", getURL, err)
}
if resp.StatusCode == 404 {
return fmt.Errorf("Issue not found: %s", getURL)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("Error retrieving %s: %d %s", getURL, resp.StatusCode, resp.Status)
}
defer util.Close(resp.Body)
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("Could not read response body: %s", err)
}
// Strip off the XSS protection chars.
parts := strings.SplitN(string(body), "\n", 2)
if len(parts) != 2 {
return fmt.Errorf("Reponse invalid format.")
}
if err := json.Unmarshal([]byte(parts[1]), &rv); err != nil {
return fmt.Errorf("Failed to decode JSON: %s", err)
}
return nil
}
func (g *Gerrit) post(suburl string, postData interface{}) error {
b, err := json.Marshal(postData)
if err != nil {
return err
}
req, err := http.NewRequest("POST", g.url+suburl, bytes.NewBuffer(b))
if err != nil {
return err
}
if err := g.addAuthenticationCookie(req); err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.client.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("Got status %s (%d)", resp.Status, resp.StatusCode)
}
return nil
}
type changeListSortable []*ChangeInfo
func (p changeListSortable) Len() int { return len(p) }
func (p changeListSortable) Less(i, j int) bool { return p[i].Created.Before(p[j].Created) }
func (p changeListSortable) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
type revisionSlice []*Revision
func (r revisionSlice) Len() int { return len(r) }
func (r revisionSlice) Less(i, j int) bool { return r[i].Created.Before(r[j].Created) }
func (r revisionSlice) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
// SearchTerm is a wrapper for search terms to pass into the Search method.
type SearchTerm struct {
Key string
Value string
}
// SearchOwner is a SearchTerm used for filtering by issue owner.
// API documentation is here: https://review.openstack.org/Documentation/user-search.html
func SearchOwner(name string) *SearchTerm {
return &SearchTerm{
Key: "owner",
Value: name,
}
}
func SearchCommit(commit string) *SearchTerm {
return &SearchTerm{
Key: "commit",
Value: commit,
}
}
func SearchStatus(status string) *SearchTerm {
return &SearchTerm{
Key: "status",
Value: status,
}
}
func SearchProject(project string) *SearchTerm {
return &SearchTerm{
Key: "project",
Value: project,
}
}
func SearchLabel(label, value string) *SearchTerm {
return &SearchTerm{
Key: "label",
Value: fmt.Sprintf("%s=%s", label, value),
}
}
// SearchModifiedAfter is a SearchTerm used for finding issues modified after
// a particular time.Time.
// API documentation is here: https://review.openstack.org/Documentation/user-search.html
func SearchModifiedAfter(after time.Time) *SearchTerm {
return &SearchTerm{
Key: "after",
Value: "\"" + strings.Trim(strings.Split(after.UTC().String(), "+")[0], " ") + "\"",
}
}
// queryString encodes query parameters in the key:val[+key:val...] format specified here:
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
func queryString(terms []*SearchTerm) string {
q := []string{}
for _, t := range terms {
q = append(q, fmt.Sprintf("%s:%s", t.Key, t.Value))
}
return strings.Join(q, " ")
}
// Sets a topic on the Gerrit change with the provided hash.
func (g *Gerrit) SetTopic(topic string, changeNum int64) error {
putData := map[string]interface{}{
"topic": topic,
}
b, err := json.Marshal(putData)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", fmt.Sprintf("%s/a/changes/%d/topic", g.url, changeNum), bytes.NewBuffer(b))
if err != nil {
return err
}
if err := g.addAuthenticationCookie(req); err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.client.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("Got status %s (%d)", resp.Status, resp.StatusCode)
}
return nil
}
// Search returns a slice of Issues which fit the given criteria.
func (g *Gerrit) Search(limit int, terms ...*SearchTerm) ([]*ChangeInfo, error) {
var issues changeListSortable
for {
data := make([]*ChangeInfo, 0)
queryLimit := util.MinInt(limit-len(issues), MAX_GERRIT_LIMIT)
skip := len(issues)
q := url.Values{}
q.Add("q", queryString(terms))
q.Add("n", strconv.Itoa(queryLimit))
q.Add("S", strconv.Itoa(skip))
searchUrl := "/changes/?" + q.Encode()
err := g.get(searchUrl, &data)
if err != nil {
return nil, fmt.Errorf("Gerrit search failed: %v", err)
}
var moreChanges bool
for _, issue := range data {
// See if there are more changes available.
moreChanges = issue.MoreChanges
// Save Created as a timestamp for sorting.
issue.Created = parseTime(issue.CreatedString)
issues = append(issues, issue)
}
if len(issues) >= limit || !moreChanges {
break
}
}
sort.Sort(issues)
return issues, nil
}
func (g *Gerrit) GetTrybotResults(issueID int64, patchsetID int64) ([]*buildbucket.Build, error) {
return g.buildbucketClient.GetTrybotsForCL(issueID, patchsetID, "gerrit", g.url)
}
// CodeReviewCache is an LRU cache for Gerrit Issues that polls in the background to determine if
// issues have been updated. If so it expells them from the cache to force a reload.
type CodeReviewCache struct {
cache *lru.Cache
gerritAPI *Gerrit
timeDelta time.Duration
mutex sync.Mutex
}
// NewCodeReviewCache returns a new cache for the given API instance, poll interval and maximum cache size.
func NewCodeReviewCache(gerritAPI *Gerrit, pollInterval time.Duration, cacheSize int) *CodeReviewCache {
ret := &CodeReviewCache{
cache: lru.New(cacheSize),
gerritAPI: gerritAPI,
timeDelta: pollInterval * 2,
}
// Start the poller.
go util.Repeat(pollInterval, nil, ret.poll)
return ret
}
// Add an issue to the cache.
func (c *CodeReviewCache) Add(key int64, value *ChangeInfo) {
c.mutex.Lock()
defer c.mutex.Unlock()
sklog.Infof("\nAdding %d", key)
c.cache.Add(key, value)
}
// Retrieve an issue from the cache.
func (c *CodeReviewCache) Get(key int64) (*ChangeInfo, bool) {
sklog.Infof("\nGetting: %d", key)
c.mutex.Lock()
defer c.mutex.Unlock()
if val, ok := c.cache.Get(key); ok {
return val.(*ChangeInfo), true
}
return nil, false
}
// Poll Gerrit for all issues that have changed in the recent past.
func (c *CodeReviewCache) poll() {
// Search for all keys that have changed in the last timeDelta duration.
issues, err := c.gerritAPI.Search(10000, SearchModifiedAfter(time.Now().Add(-c.timeDelta)))
if err != nil {
sklog.Errorf("Error polling Gerrit: %s", err)
return
}
c.mutex.Lock()
defer c.mutex.Unlock()
for _, issue := range issues {
sklog.Infof("\nRemoving: %d", issue.Issue)
c.cache.Remove(issue.Issue)
}
}
// ContainsAny returns true if the provided ChangeInfo slice contains any
// change with the same issueID as id.
func ContainsAny(id int64, changes []*ChangeInfo) bool {
for _, c := range changes {
if id == c.Issue {
return true
}
}
return false
}