blob: 7bf7f433ef0e6ef1c4884e65a1bfdc4b12636bc9 [file] [log] [blame]
package gerrit
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/user"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/golang/groupcache/lru"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/buildbucket"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"golang.org/x/time/rate"
)
var (
// ErrNotFound indicates that the requested item was not found.
ErrNotFound = errors.New("Requested item was not found")
// ErrBothChangeAndCommitID indicates that both commit and changeID were set.
ErrBothChangeAndCommitID = errors.New("commit and change ID cannot both be set")
)
const (
// TimeFormat is the timestamp format used by the Gerrit API.
TimeFormat = "2006-01-02 15:04:05.999999"
// GerritSkiaURL is the URL of Skia's Gerrit instance.
GerritSkiaURL = "https://skia-review.googlesource.com"
maxSearchResultLimit = 500
// AuthScope is the auth scope needed to use the Gerrit API.
AuthScope = auth.ScopeGerrit
// ChangeStatusAbandoned indicates the change is abandoned.
ChangeStatusAbandoned = "ABANDONED"
// ChangeStatusMerged indicates the change is merged.
ChangeStatusMerged = "MERGED"
// ChangeStatusNew indicates the change is new.
ChangeStatusNew = "NEW"
// ChangeStatusNew indicates the change is open.
ChangeStatusOpen = "OPEN"
// LabelCodeReview is the label used for code review.
LabelCodeReview = "Code-Review"
// LabelCodeReviewDisapprove indicates code review disapproval.
LabelCodeReviewDisapprove = -1
// LabelCodeReviewNone indicates that the change has not been code reviewed.
LabelCodeReviewNone = 0
// LabelCodeReviewApprove indicates code review approval.
LabelCodeReviewApprove = 1
// LabelCodeReviewSelfApprove indicates code review self-approval.
LabelCodeReviewSelfApprove = 2
// LabelCommitQueue is the label used for the commit queue.
LabelCommitQueue = "Commit-Queue"
// LabelCommitQueueNone indicates that the commit queue is not running for
// this change.
LabelCommitQueueNone = 0
// LabelCommitQueueDryRun indicates that the commit queue should run in dry
// run mode for this change.
LabelCommitQueueDryRun = 1
// LabelCommitQueueSubmit indicates that the commit queue should run for
// this change.
LabelCommitQueueSubmit = 2
// LabelAndroidAutoSubmit indicates whether the change should be submitted
// when it is approved. For Android hosts only.
LabelAndroidAutoSubmit = "Autosubmit"
// LabelAndroidAutoSubmitNone indicates that the change should not be
// submitted when it is approved.
LabelAndroidAutoSubmitNone = 0
// LabelAndroidAutoSubmitSubmit indicates that the change should be
// submitted when it is approved.
LabelAndroidAutoSubmitSubmit = 1
// LabelChromiumAutoSubmit indicates whether the change should be submitted
// when it is approved. For Chromium hosts only.
LabelChromiumAutoSubmit = "Auto-Submit"
// LabelChromiumAutoSubmitNone indicates that the change should not be
// submitted when it is approved.
LabelChromiumAutoSubmitNone = 0
// LabelChromiumAutoSubmitSubmit indicates that the change should be
// submitted when it is approved.
LabelChromiumAutoSubmitSubmit = 1
// LabelPresubmitReady indicates whether the presubmit checks should run for
// this change.
LabelPresubmitReady = "Presubmit-Ready"
// LabelPresubmitReadyNone indicates that the presubmit checks should not
// run for this change.
LabelPresubmitReadyNone = 0
// LabelPresubmitReadyEnable indicates that the presubmit checks should run
// for this change.
LabelPresubmitReadyEnable = 1
// LabelPresubmitVerified indicates whether the presubmit checks ran
// successfully for this change.
LabelPresubmitVerified = "Presubmit-Verified"
// LabelPresubmitVerifiedRejected indicates that the presubmit checks failed
// for this change.
LabelPresubmitVerifiedRejected = -1
// LabelPresubmitVerifiedRunning indicates that the presubmit checks have
// not finished for this change.
LabelPresubmitVerifiedRunning = 0
// LabelPresubmitVerifiedAccepted indicates that the presubmit checks
// succeeded for this change.
LabelPresubmitVerifiedAccepted = 2
// LabelVerified indicates whether the presubmit checks ran successfully for
// this change.
LabelVerified = "Verified"
// LabelVerifiedRejected indicates that the presubmit checks failed for this
// change.
LabelVerifiedRejected = -1
// LabelVerifiedRunning indicates that the presubmit checks have not
// finished for this change.
LabelVerifiedRunning = 0
// LabelVerifiedAccepted indicates that the presubmit checks succeeded for
// this change.
LabelVerifiedAccepted = 1
// LabelBotCommit indicates self-approval by a trusted bot.
LabelBotCommit = "Bot-Commit"
// LabelBotCommitNone indicates that the change is not self-approved by a
// trusted bot.
LabelBotCommitNone = 0
// LabelBotCommitApproved indicates that the change is self-approved by a
// trusted bot.
LabelBotCommitApproved = 1
// URLTmplChange is the template for a change URL.
URLTmplChange = "/changes/%s/detail?o=ALL_REVISIONS&o=SUBMITTABLE"
urlCommitMsgHook = "/tools/hooks/commit-msg"
// Kinds of patchsets.
PatchSetKindMergeFirstParentUpdate = "MERGE_FIRST_PARENT_UPDATE"
PatchSetKindNoChange = "NO_CHANGE"
PatchSetKindNoCodeChange = "NO_CODE_CHANGE"
PatchSetKindCodeChange = "CODE_CHANGE"
PatchSetKindRework = "REWORK"
PatchSetKindTrivialRebase = "TRIVIAL_REBASE"
// authSuffix is added to the Gerrit API URL to force authentication.
authSuffix = "/a"
// extractReg is the regular expression used by ExtractIssueFromCommit.
extractRegTmpl = `^\s*Reviewed-on:.*%s.*/([0-9]+)\s*$`
// ChangeRefPrefix is the prefix used by change refs in Gerrit, which are of
// this form:
//
// refs/changes/46/4546/1
// | | |
// | | +-> Patch set.
// | |
// | +-> Issue ID.
// |
// +-> Last two digits of Issue ID.
ChangeRefPrefix = "refs/changes/"
// ErrCannotRebaseMergeCommits as a substring of an error indicates that we
// tried to rebase a merge commit.
ErrCannotRebaseMergeCommits = "Cannot rebase merge commits"
// ErrMergeConflict as a substring of an error message indicates that a
// merge conflict occurred.
ErrMergeConflict = "conflict during merge"
// ErrUnsubmittedDependend as a substring of an error message indicates
// that a dependend CL has not been submitted yet.
ErrUnsubmittedDependend = "Depends on change that was not submitted"
// ErrEmptyCommit as a substring of an error message indicates that
// submission was rejected due to an empty commit.
ErrEmptyCommit = "Change could not be merged because the commit is empty"
// ErrNoChanges as a substring of an error message indicates that there were
// no changes to apply. Generally we can ignore this error.
ErrNoChanges = "no changes were made"
// These were copied from the defaults used by gitfs:
// https://gerrit.googlesource.com/gitfs/+show/59c1163fd1737445281f2339399b2b986b0d30fe/gitiles/client.go#102
// Hopefully they apply to Gerrit as well.
defaultMaxQPS = 4.0
defaultMaxBurst = 40
// Gerrit's magic path for the commit message. See:
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-id
CommitMsgFileName = "/COMMIT_MSG"
// HTTP header used to enable tracing.
HeaderTracing = "X-Gerrit-Trace"
)
var (
TrivialPatchSetKinds = []string{
PatchSetKindTrivialRebase,
PatchSetKindNoChange,
PatchSetKindNoCodeChange,
}
changeIdRegex = regexp.MustCompile(`\s*Change-Id:\s*(\w+)`)
)
// The different notify options supported by Gerrit. See the notify property
// in https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-input
type NotifyOption string
const NotifyNone NotifyOption = "NONE"
const NotifyOwner NotifyOption = "OWNER"
const NotifyOwnerReviewers NotifyOption = "OWNER_REVIEWERS"
const NotifyAll NotifyOption = "ALL"
const NotifyDefault NotifyOption = ""
// The different recipient types supported by Gerrit.
// https://gerrit-review.googlesource.com/Documentation/user-notify.html#recipient-types
type RecipientType string
const RecipientTo = "TO"
const RecipientCC = "CC"
const RecipientBCC = "BCC"
type cherryPickPostData struct {
Message string `json:"message"`
Destination string `json:"destination"`
}
// ChangeInfoMessage contains information about Gerrit messages.
type ChangeInfoMessage struct {
Tag string `json:"tag"`
Message string `json:"message"`
}
type createChangePostData struct {
Project string `json:"project"`
Subject string `json:"subject"`
Branch string `json:"branch"`
Topic string `json:"topic"`
Status string `json:"status"`
BaseCommit string `json:"base_commit,omitempty"`
BaseChange string `json:"base_change,omitempty"`
}
// ChangeInfo contains information about a Gerrit issue.
type ChangeInfo struct {
Id string `json:"id"`
Insertions int `json:"insertions"`
Deletions int `json:"deletions"`
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"`
Messages []ChangeInfoMessage `json:"messages"`
Reviewers struct {
CC []*Person `json:"CC"`
Reviewer []*Person `json:"REVIEWER"`
} `json:"reviewers"`
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 *Person `json:"owner"`
Status string `json:"status"`
Submittable bool `json:"submittable"`
Topic string `json:"topic"`
WorkInProgress bool `json:"work_in_progress"`
CherrypickOfChange int `json:"cherry_pick_of_change"`
CherrypickOfPatchSet int `json:"cherry_pick_of_patch_set"`
}
// GetNonTrivialPatchSets finds the set of non-trivial patchsets. Returns the
// Revisions in order of patchset number. Note that this is only correct for
// Chromium Gerrit instances because it makes Chromium-specific assumptions.
func (ci *ChangeInfo) GetNonTrivialPatchSets() []*Revision {
allPatchSets := make([]int, 0, len(ci.Revisions))
byNumber := make(map[int]*Revision, len(ci.Revisions))
for _, rev := range ci.Revisions {
allPatchSets = append(allPatchSets, int(rev.Number))
byNumber[int(rev.Number)] = rev
}
sort.Ints(allPatchSets)
rv := make([]*Revision, 0, len(ci.Revisions))
for idx, num := range allPatchSets {
rev := byNumber[num]
// Skip the last patch set for merged CLs, since it is auto-
// generated for Chromium projects.
if ci.Status == ChangeStatusMerged && idx == len(allPatchSets)-1 {
continue
}
if !util.In(rev.Kind, TrivialPatchSetKinds) {
rv = append(rv, rev)
}
}
return rv
}
// The RelatedChangesInfo entity contains information about related changes.
type RelatedChangesInfo struct {
Changes []*RelatedChangeAndCommitInfo `json:"changes"`
}
// RelatedChangeAndCommitInfo entity contains information about a related change and commit.
type RelatedChangeAndCommitInfo struct {
ChangeId string `json:"change_id"`
Issue int64 `json:"_change_number"`
Revision int64 `json:"_revision_number"`
Status string `json:"status"`
}
// IsClosed returns true iff the issue corresponding to the ChangeInfo is
// abandoned or merged.
func (ci *ChangeInfo) IsClosed() bool {
return (ci.Status == ChangeStatusAbandoned ||
ci.Status == ChangeStatusMerged)
}
// IsMerged returns true iff the issue corresponding to the ChangeInfo is
// merged.
func (ci *ChangeInfo) IsMerged() bool {
return ci.Status == ChangeStatusMerged
}
// GetAbandonReason returns the reason entered by the user that abandoned the change.
func (ci *ChangeInfo) GetAbandonReason(ctx context.Context) string {
if ci.Status != ChangeStatusAbandoned {
// There is no abandon reason if the change isn't abandoned.
return ""
}
for i := len(ci.Messages) - 1; i >= 0; i-- {
msg := ci.Messages[i]
if msg.Tag != "autogenerated:gerrit:abandon" {
continue
}
if msg.Message == "Abandoned" {
// An abandon reason wasn't provided.
return ""
}
return strings.TrimPrefix(msg.Message, "Abandoned\n\n")
}
return ""
}
// Person describes a person in Gerrit.
type Person struct {
AccountID int `json:"_account_id"`
Email string `json:"email"`
Name string `json:"name"`
}
// LabelEntry describes a label set on a Change in Gerrit.
type LabelEntry struct {
All []*LabelDetail
Values map[string]string
DefaultValue int
}
// LabelDetail provides details about a label set on a Change in Gerrit.
type LabelDetail struct {
Name string `json:"name"`
Email string `json:"email"`
Date string `json:"date"`
Value int `json:"value"`
AccountID int `json:"_account_id"`
}
// FileInfoStatus is the type of 'Status' in FileInfo.
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
type FileInfoStatus string
const (
FileAdded FileInfoStatus = "A"
FileCopied FileInfoStatus = "C"
FileDeleted FileInfoStatus = "D"
FileModified FileInfoStatus = ""
FileRenamed FileInfoStatus = "R"
FileRewritten FileInfoStatus = "W"
)
// AllFileInfoStatus is all valid values of type FileInfoStatus.
var AllFileInfoStatus = []FileInfoStatus{
FileAdded,
FileCopied,
FileDeleted,
FileModified,
FileRenamed,
FileRewritten,
}
// FileInfo provides information about changes to a File in Gerrit.
type FileInfo struct {
Status FileInfoStatus `json:"status"`
Binary bool `json:"binary"`
OldPath string `json:"old_path"`
LinesInserted int `json:"lines_inserted"`
LinesDeleted int `json:"lines_deleted"`
SizeDelta int `json:"size_delta"`
Size int `json:"size"`
}
// 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:"-"`
Kind string `json:"kind"`
Ref string `json:"ref"`
}
// GerritInterface describes interactions with a Gerrit host.
type GerritInterface interface {
Abandon(context.Context, *ChangeInfo, string) error
AddComment(context.Context, *ChangeInfo, string) error
AddCC(context.Context, *ChangeInfo, []string) error
Approve(context.Context, *ChangeInfo, string) error
Config() *Config
CreateChange(context.Context, string, string, string, string, string) (*ChangeInfo, error)
CreateCherryPickChange(context.Context, string, string, string, string) (*ChangeInfo, error)
DeleteChangeEdit(context.Context, *ChangeInfo) error
DeleteFile(context.Context, *ChangeInfo, string) error
DeleteVote(context.Context, int64, string, int, NotifyOption, bool) error
Disapprove(context.Context, *ChangeInfo, string) error
DownloadCommitMsgHook(ctx context.Context, dest string) error
EditFile(context.Context, *ChangeInfo, string, string) error
ExtractIssueFromCommit(string) (int64, error)
Files(ctx context.Context, issue int64, patch string) (map[string]*FileInfo, error)
GetChange(ctx context.Context, id string) (*ChangeInfo, error)
GetCommit(ctx context.Context, issue int64, revision string) (*CommitInfo, error)
GetContent(context.Context, int64, string, string) (string, error)
GetFileNames(ctx context.Context, issue int64, patch string) ([]string, error)
GetFilesToContent(ctx context.Context, issue int64, revision string) (map[string]string, error)
GetIssueProperties(context.Context, int64) (*ChangeInfo, error)
GetPatch(context.Context, int64, string) (string, error)
GetRepoUrl() string
GetTrybotResults(context.Context, int64, int64) ([]*buildbucketpb.Build, error)
GetUserEmail(context.Context) (string, error)
Initialized() bool
IsBinaryPatch(ctx context.Context, issue int64, patch string) (bool, error)
MoveFile(context.Context, *ChangeInfo, string, string) error
NoScore(context.Context, *ChangeInfo, string) error
PublishChangeEdit(context.Context, *ChangeInfo) error
Rebase(context.Context, *ChangeInfo, string, bool) error
RemoveFromCQ(context.Context, *ChangeInfo, string) error
Search(context.Context, int, bool, ...*SearchTerm) ([]*ChangeInfo, error)
SelfApprove(context.Context, *ChangeInfo, string) error
SendToCQ(context.Context, *ChangeInfo, string) error
SendToDryRun(context.Context, *ChangeInfo, string) error
SetCommitMessage(context.Context, *ChangeInfo, string) error
SetReadyForReview(context.Context, *ChangeInfo) error
SetReview(context.Context, *ChangeInfo, string, map[string]int, []string, NotifyOption, NotifyDetails, string, int, []*AttentionSetInput) error
SetTopic(context.Context, string, int64) error
SetTraceIDPrefix(traceIdPrefix string)
Submit(context.Context, *ChangeInfo) error
SubmittedTogether(context.Context, *ChangeInfo) ([]*ChangeInfo, int, error)
Url(int64) string
}
// Gerrit is an object used for interacting with the issue tracker.
type Gerrit struct {
cfg *Config
client *http.Client
BuildbucketClient *buildbucket.Client
apiUrl string
baseUrl string
repoUrl string
extractRegEx *regexp.Regexp
rl *rate.Limiter
traceIdPrefix string
}
// NewGerrit returns a new Gerrit instance.
func NewGerrit(gerritUrl string, client *http.Client) (*Gerrit, error) {
return NewGerritWithConfig(ConfigChromium, gerritUrl, client)
}
// NewGerritWithConfig returns a new Gerrit instance which uses the given
// Config.
func NewGerritWithConfig(cfg *Config, gerritUrl string, client *http.Client) (*Gerrit, error) {
return NewGerritWithConfigAndRateLimits(cfg, gerritUrl, client, defaultMaxQPS, defaultMaxBurst)
}
// NewGerritWithConfigAndRateLimits returns a new Gerrit instance which uses the given
// Config and rate limit options.
func NewGerritWithConfigAndRateLimits(cfg *Config, gerritUrl string, client *http.Client, maxQPS float64, maxBurst int) (*Gerrit, error) {
parsedUrl, err := url.Parse(gerritUrl)
if err != nil {
return nil, skerr.Fmt("Unable to parse gerrit URL: %s", err)
}
regExStr := fmt.Sprintf(extractRegTmpl, parsedUrl.Host)
extractRegEx, err := regexp.Compile(regExStr)
if err != nil {
return nil, skerr.Fmt("Unable to compile regular expression '%s'. Error: %s", regExStr, err)
}
if client == nil {
return nil, skerr.Fmt("Gerrit requires a non-nil authenticated http.Client with the Gerrit scope.")
}
baseUrl := strings.TrimSuffix(gerritUrl, "/")
return &Gerrit{
cfg: cfg,
apiUrl: baseUrl + authSuffix,
baseUrl: baseUrl,
repoUrl: strings.Replace(baseUrl, "-review", "", 1),
client: client,
BuildbucketClient: buildbucket.NewClient(client),
extractRegEx: extractRegEx,
rl: rate.NewLimiter(rate.Limit(maxQPS), maxBurst),
}, nil
}
// Config returns the Config object used by this Gerrit.
func (g *Gerrit) Config() *Config {
return g.cfg
}
// 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")
}
// GitCookieAuthDaemonPath returns the default path that git_cookie_authdaemon
// writes to. See infra/git_cookie_authdaemon
func GitCookieAuthDaemonPath() (string, error) {
usr, err := user.Current()
if err != nil {
return "", skerr.Fmt("Unable to retrieve user for git_auth_deamon default cookie path.")
}
return filepath.Join(usr.HomeDir, ".git-credential-cache", "cookie"), nil
}
func parseTime(t string) time.Time {
parsed, _ := time.Parse(TimeFormat, t)
return parsed
}
// Initialized returns false if the implementation of GerritInterface has not
// been initialized (i.e. it is a pointer to nil).
func (g *Gerrit) Initialized() bool {
return g != nil
}
// 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.baseUrl
}
return fmt.Sprintf("%s/c/%d", g.baseUrl, issueID)
}
// AccountDetails provides details about an account in Gerrit.
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(ctx context.Context) (string, error) {
url := "/accounts/self/detail"
var account AccountDetails
if err := g.get(ctx, url, &account, nil); err != nil {
return "", skerr.Wrapf(err, "Failed to retrieve user")
}
return account.Email, nil
}
// GetRepoUrl returns the url of the Googlesource repo.
func (g *Gerrit) GetRepoUrl() string {
return g.repoUrl
}
// ExtractIssueFromCommit returns the issue id by parsing the commit message of
// a landed commit. It expects the commit message to contain one line in this format:
//
// Reviewed-on: https://skia-review.googlesource.com/999999
//
// where the digits at the end are the issue id.
func (g *Gerrit) ExtractIssueFromCommit(commitMsg string) (int64, error) {
scanner := bufio.NewScanner(strings.NewReader(commitMsg))
for scanner.Scan() {
line := scanner.Text()
// Reminder, this regex has the review url (e.g. skia-review.googlesource.com) baked into it.
result := g.extractRegEx.FindStringSubmatch(line)
if len(result) == 2 {
ret, err := strconv.ParseInt(result[1], 10, 64)
if err != nil {
return 0, skerr.Wrapf(err, "parsing issue id '%s'", result[1])
}
return ret, nil
}
}
return 0, skerr.Fmt("unable to find Reviewed-on line")
}
// Fix up a ChangeInfo object, received via the Gerrit API, to contain all of
// the fields it is expected to contain. Returns the ChangeInfo object for
// convenience.
func fixupChangeInfo(ci *ChangeInfo) *ChangeInfo {
// Set created, updated and submitted timestamps. Also set the committed flag.
ci.Created = parseTime(ci.CreatedString)
ci.Updated = parseTime(ci.UpdatedString)
if ci.SubmittedString != "" {
ci.Submitted = parseTime(ci.SubmittedString)
ci.Committed = true
}
// Make patchset objects with the revision IDs and created timestamps.
patchsets := make([]*Revision, 0, len(ci.Revisions))
for id, r := range ci.Revisions {
// Fill in the missing fields.
r.ID = id
r.Created = parseTime(r.CreatedString)
patchsets = append(patchsets, r)
}
sort.Sort(revisionSlice(patchsets))
ci.Patchsets = patchsets
return ci
}
// GetIssueProperties returns a fully filled-in ChangeInfo object, as opposed to
// the partial data returned by Gerrit's search endpoint.
// If the given issue cannot be found ErrNotFound is returned as error.
func (g *Gerrit) GetIssueProperties(ctx context.Context, issue int64) (*ChangeInfo, error) {
return g.GetChange(ctx, fmt.Sprintf("%d", issue))
}
// GetChange returns the ChangeInfo object for the given ID.
func (g *Gerrit) GetChange(ctx context.Context, id string) (*ChangeInfo, error) {
url := fmt.Sprintf(URLTmplChange, id)
fullIssue := &ChangeInfo{}
if err := g.get(ctx, url, fullIssue, ErrNotFound); err != nil {
// Pass ErrNotFound through unchanged so calling functions can check for it.
if err == ErrNotFound {
return nil, err
}
return nil, skerr.Wrapf(err, "failed to load details for issue %q", id)
}
return fixupChangeInfo(fullIssue), nil
}
// GetPatchsetIDs is a convenience function that returns the sorted list of patchset IDs.
func (ci *ChangeInfo) GetPatchsetIDs() []int64 {
ret := make([]int64, len(ci.Patchsets))
for idx, patchSet := range ci.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(ctx context.Context, issue int64, revision string) (string, error) {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return "", skerr.Wrap(err)
}
u := fmt.Sprintf("%s/changes/%d/revisions/%s/patch", g.apiUrl, issue, revision)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", skerr.Wrap(err)
}
resp, err := g.doRequest(req)
if err != nil {
return "", skerr.Wrapf(err, "Failed to GET %s", u)
}
if resp.StatusCode == http.StatusNotFound {
return "", skerr.Fmt("Issue %d not found: %s", issue, u)
}
if resp.StatusCode >= http.StatusBadRequest {
return "", skerr.Fmt("Retrieving %s: %d %s", u, resp.StatusCode, resp.Status)
}
defer util.Close(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", skerr.Wrapf(err, "Could not read response body")
}
data, err := base64.StdEncoding.DecodeString(string(body))
if err != nil {
return "", skerr.Wrapf(err, "Could not base64 decode response body")
}
// Extract out only the patch.
tokens := strings.SplitN(string(data), "---", 2)
if len(tokens) != 2 {
return "", skerr.Fmt("Gerrit patch response was invalid: %s", string(data))
}
patch := tokens[1]
return patch, nil
}
// CommitInfo captures information about the commit of a revision (patchset)
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
type CommitInfo struct {
Commit string `json:"commit"`
Parents []*CommitInfo `json:"parents"`
Subject string `json:"subject"`
Message string `json:"message"`
Author *Person `json:"author"`
}
// GetCommit retrieves the commit that corresponds to the patch identified by issue and revision.
// It allows to retrieve the parent commit on which the given patchset is based on.
// See: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-commit
func (g *Gerrit) GetCommit(ctx context.Context, issue int64, revision string) (*CommitInfo, error) {
path := fmt.Sprintf("/changes/%d/revisions/%s/commit", issue, revision)
ret := &CommitInfo{}
err := g.get(ctx, path, ret, nil)
if err != nil {
return nil, skerr.Wrap(err)
}
return ret, nil
}
// GetContent gets the content of a file from the specified revision.
// See: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-content
func (g *Gerrit) GetContent(ctx context.Context, issue int64, revision string, filePath string) (string, error) {
// Encode the filePath to convert paths like /COMMIT_MSG into %2FCOMMIT_MSG.
filePath = url.QueryEscape(filePath)
u := fmt.Sprintf("%s/changes/%d/revisions/%s/files/%s/content", g.apiUrl, issue, revision, filePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", skerr.Wrap(err)
}
resp, err := g.doRequest(req)
if err != nil {
return "", skerr.Wrapf(err, "Failed to GET %s", u)
}
if resp.StatusCode >= http.StatusBadRequest {
return "", skerr.Fmt("Error retrieving %s: %d %s", u, resp.StatusCode, resp.Status)
}
defer util.Close(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", skerr.Wrapf(err, "Could not read response body")
}
data, err := base64.StdEncoding.DecodeString(string(body))
if err != nil {
return "", skerr.Wrapf(err, "Could not base64 decode response body")
}
return string(data), nil
}
// GetFilesToContent returns a map of files in the specified issue+revision to
// their content.
func (g *Gerrit) GetFilesToContent(ctx context.Context, issue int64, revision string) (map[string]string, error) {
filesToContent := map[string]string{}
files, err := g.GetFileNames(ctx, issue, revision)
if err != nil {
return nil, skerr.Wrap(err)
}
for _, f := range files {
content, err := g.GetContent(ctx, issue, revision, f)
if err != nil {
// Deleted files are expected to return 404s. Actual http.StatusNotFound
// message should be "404 Not Found", but httputils.GetWithContext wraps
// 404s as errors that contain the text "status code 404". So check for
// both strings to be safe.
if strings.Contains(err.Error(), "404 Not Found") || strings.Contains(err.Error(), "status code 404") {
content = ""
} else {
return nil, skerr.Wrap(err)
}
}
filesToContent[f] = content
}
return filesToContent, skerr.Wrap(err)
}
type Reviewer struct {
Reviewer string `json:"reviewer"`
}
type NotifyInfo struct {
Accounts []string `json:"accounts"`
}
type NotifyDetails map[RecipientType]*NotifyInfo
type AttentionSetInput struct {
User string `json:"user"`
Reason string `json:"reason"`
}
// 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
// notifyDetails contains additional information about whom to notify about the update. See details in
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-input
// onBehalfOf is expected to be the accountId the review should be posted on
// behalf of. Set to 0 to not use this functionality.
// attentionSetInputs contains details for adding users to the attension set. See details in
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-input
func (g *Gerrit) SetReview(ctx context.Context, issue *ChangeInfo, message string, labels map[string]int, reviewers []string, notify NotifyOption, notifyDetails NotifyDetails, tag string, onBehalfOf int, attentionSetInputs []*AttentionSetInput) error {
postData := map[string]interface{}{
"message": message,
"labels": labels,
}
if notify != NotifyDefault {
postData["notify"] = notify
}
if notifyDetails != nil {
postData["notify_details"] = notifyDetails
}
if tag != "" {
postData["tag"] = tag
}
if onBehalfOf != 0 {
postData["on_behalf_of"] = onBehalfOf
}
if len(attentionSetInputs) > 0 {
postData["add_to_attention_set"] = attentionSetInputs
}
if len(reviewers) > 0 {
revs := make([]*Reviewer, 0, len(reviewers))
for _, r := range reviewers {
revs = append(revs, &Reviewer{
Reviewer: r,
})
}
postData["reviewers"] = revs
}
latestPatchset := issue.Patchsets[len(issue.Patchsets)-1]
return g.postJson(ctx, fmt.Sprintf("/changes/%s/revisions/%s/review", FullChangeId(issue), latestPatchset.ID), postData)
}
type reviewerWithState struct {
Reviewer string `json:"reviewer"`
State string `json:"state"`
}
// AddCC adds CCs to the issues.
func (g *Gerrit) AddCC(ctx context.Context, issue *ChangeInfo, ccList []string) error {
ccs := make([]*reviewerWithState, 0, len(ccList))
for _, c := range ccList {
ccs = append(ccs, &reviewerWithState{
Reviewer: c,
State: "CC",
})
}
postData := map[string]interface{}{"reviewers": ccs}
latestPatchset := issue.Patchsets[len(issue.Patchsets)-1]
return g.postJson(ctx, fmt.Sprintf("/changes/%s/revisions/%s/review", FullChangeId(issue), latestPatchset.ID), postData)
}
// AddComment adds a message to the issue.
func (g *Gerrit) AddComment(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, map[string]int{}, nil, "", nil, "", 0, nil)
}
// Utility methods for interacting with the COMMITQUEUE_LABEL.
// SendToDryRun sets the Commit Queue dry run labels on the Change.
func (g *Gerrit) SendToDryRun(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, g.cfg.SetDryRunLabels, nil, "", nil, "", 0, nil)
}
// SendToCQ sets the Commit Queue labels on the Change.
func (g *Gerrit) SendToCQ(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, g.cfg.SetCqLabels, nil, "", nil, "", 0, nil)
}
// RemoveFromCQ unsets the Commit Queue labels on the Change.
func (g *Gerrit) RemoveFromCQ(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, g.cfg.NoCqLabels, nil, "", nil, "", 0, nil)
}
// Utility methods for interacting with the CODEREVIEW_LABEL.
// Approve sets the Code Review label to indicate approval.
func (g *Gerrit) Approve(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, map[string]int{LabelCodeReview: LabelCodeReviewApprove}, nil, "", nil, "", 0, nil)
}
// NoScore unsets the Code Review label.
func (g *Gerrit) NoScore(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, map[string]int{LabelCodeReview: LabelCodeReviewNone}, nil, "", nil, "", 0, nil)
}
// Disapprove sets the Code Review label to indicate disapproval.
func (g *Gerrit) Disapprove(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, map[string]int{LabelCodeReview: LabelCodeReviewDisapprove}, nil, "", nil, "", 0, nil)
}
// SelfApprove sets the Code Review label to indicate self-approval.
func (g *Gerrit) SelfApprove(ctx context.Context, issue *ChangeInfo, message string) error {
return g.SetReview(ctx, issue, message, g.cfg.SelfApproveLabels, nil, "", nil, "", 0, nil)
}
// Abandon abandons the issue with the given message.
func (g *Gerrit) Abandon(ctx context.Context, issue *ChangeInfo, message string) error {
postData := map[string]interface{}{
"message": message,
}
return g.postJson(ctx, fmt.Sprintf("/changes/%s/abandon", FullChangeId(issue)), postData)
}
// get retrieves the given sub URL and populates 'rv' with the result.
// If notFoundError is not nil it will be returned if the requested item doesn't
// exist.
func (g *Gerrit) get(ctx context.Context, suburl string, rv interface{}, notFoundError error) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
getURL := g.apiUrl + suburl
req, err := http.NewRequestWithContext(ctx, http.MethodGet, getURL, nil)
if err != nil {
return err
}
resp, err := g.doRequest(req)
if err != nil {
return skerr.Wrapf(err, "Failed to GET %s", getURL)
}
if resp.StatusCode == http.StatusNotFound {
if notFoundError != nil {
return notFoundError
}
return skerr.Wrapf(err, "Resource not found: %s", getURL)
}
defer util.Close(resp.Body)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return skerr.Wrapf(err, "Could not read response body")
}
body := string(bodyBytes)
if resp.StatusCode >= http.StatusBadRequest {
return skerr.Fmt("Error retrieving %s: %d %s; response:\n%s", getURL, resp.StatusCode, resp.Status, body)
}
// Strip off the XSS protection chars.
parts := strings.SplitN(body, "\n", 2)
if len(parts) != 2 {
return skerr.Fmt("Reponse invalid format; response:\n%s", body)
}
if err := json.Unmarshal([]byte(parts[1]), &rv); err != nil {
return skerr.Wrapf(err, "Failed to decode JSON: response:\n%s", body)
}
return nil
}
func (g *Gerrit) post(ctx context.Context, suburl string, b []byte) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.apiUrl+suburl, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.doRequest(req)
if err != nil {
return err
}
defer util.Close(resp.Body)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return skerr.Wrapf(err, "Could not read response body")
}
body := string(bodyBytes)
if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
return skerr.Fmt("Got status %s (%d); response:\n%s", resp.Status, resp.StatusCode, body)
}
return nil
}
func (g *Gerrit) postJson(ctx context.Context, suburl string, postData interface{}) error {
b, err := json.Marshal(postData)
if err != nil {
return err
}
return g.post(ctx, suburl, b)
}
func (g *Gerrit) put(ctx context.Context, suburl string, b []byte) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, g.apiUrl+suburl, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.doRequest(req)
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
return skerr.Fmt("Got status %s (%d)", resp.Status, resp.StatusCode)
}
return nil
}
func (g *Gerrit) putJson(ctx context.Context, suburl string, data interface{}) error {
b, err := json.Marshal(data)
if err != nil {
return err
}
return g.put(ctx, suburl, b)
}
func (g *Gerrit) delete(ctx context.Context, suburl string) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, g.apiUrl+suburl, nil)
if err != nil {
return err
}
resp, err := g.doRequest(req)
if err != nil {
return err
}
defer util.Close(resp.Body)
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
return skerr.Fmt("Got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes))
}
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 {
if !util.TimeIsZero(r[i].Created) && !util.TimeIsZero(r[j].Created) {
return r[i].Created.Before(r[j].Created)
}
return r[i].Number < r[j].Number
}
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,
}
}
// SearchCommit is a SearchTerm used for filtering by commit.
func SearchCommit(commit string) *SearchTerm {
return &SearchTerm{
Key: "commit",
Value: commit,
}
}
// SearchStatus is a SearchTerm used for filtering by status.
func SearchStatus(status string) *SearchTerm {
return &SearchTerm{
Key: "status",
Value: status,
}
}
// SearchProject is a SearchTerm used for filtering by project.
func SearchProject(project string) *SearchTerm {
return &SearchTerm{
Key: "project",
Value: project,
}
}
// SearchBranch is a SearchTerm used for filtering by branch.
func SearchBranch(branch string) *SearchTerm {
return &SearchTerm{
Key: "branch",
Value: branch,
}
}
// SearchTopic is a SearchTerm used for filtering by topic.
func SearchTopic(topic string) *SearchTerm {
return &SearchTerm{
Key: "topic",
Value: topic,
}
}
// SearchLabel is a SearchTerm used for filtering by label.
func SearchLabel(label, value string) *SearchTerm {
return &SearchTerm{
Key: "label",
Value: fmt.Sprintf("%s=%s", label, value),
}
}
// SearchCherrypickOf is a SearchTerm used for finding all cherrypicks of the specified change.
func SearchCherrypickOf(changeNum int) *SearchTerm {
return &SearchTerm{
Key: "cherrypickof",
Value: fmt.Sprintf("%d", changeNum),
}
}
// 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, " ")
}
// SetTopic sets a topic on the Gerrit change with the provided id.
func (g *Gerrit) SetTopic(ctx context.Context, topic string, changeNum int64) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
putData := map[string]interface{}{
"topic": topic,
}
b, err := json.Marshal(putData)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s/changes/%d/topic", g.apiUrl, changeNum), bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.doRequest(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return skerr.Fmt("Got status %s (%d)", resp.Status, resp.StatusCode)
}
return nil
}
// GetDependencies returns a slice of all dependencies around the specified change. See
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-related-changes
func (g *Gerrit) GetDependencies(ctx context.Context, changeNum int64, revision int) ([]*RelatedChangeAndCommitInfo, error) {
data := RelatedChangesInfo{}
err := g.get(ctx, fmt.Sprintf("/changes/%d/revisions/%d/related", changeNum, revision), &data, nil)
if err != nil {
return nil, skerr.Wrap(err)
}
return data.Changes, nil
}
// HasOpenDependency returns true if there is an active direct dependency of the specified change.
func (g *Gerrit) HasOpenDependency(ctx context.Context, changeNum int64, revision int) (bool, error) {
dependencies, err := g.GetDependencies(ctx, changeNum, revision)
if err != nil {
return false, skerr.Wrap(err)
}
// Find the target change num in the chain of dependencies.
targetChangeIdx := 0
for idx, relatedChange := range dependencies {
if relatedChange.Issue == changeNum {
targetChangeIdx = idx
break
}
}
// See if the target change has an open dependency.
if len(dependencies) > targetChangeIdx+1 {
// The next change will be the direct dependency.
dependency := dependencies[targetChangeIdx+1]
if dependency.Status != ChangeStatusAbandoned && dependency.Status != ChangeStatusMerged {
// If the dependency is not closed then it is an active dependency.
return true, nil
}
}
return false, nil
}
// Search returns a slice of Issues which fit the given criteria.
func (g *Gerrit) Search(ctx context.Context, limit int, sortResults bool, terms ...*SearchTerm) ([]*ChangeInfo, error) {
var issues changeListSortable
for {
data := make([]*ChangeInfo, 0)
queryLimit := util.MinInt(limit-len(issues), maxSearchResultLimit)
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(ctx, searchURL, &data, nil)
if err != nil {
return nil, skerr.Wrapf(err, "Gerrit search failed: %s", searchURL)
}
var moreChanges bool
for _, issue := range data {
// See if there are more changes available.
moreChanges = issue.MoreChanges
issues = append(issues, fixupChangeInfo(issue))
}
if len(issues) >= limit || !moreChanges {
break
}
}
if sortResults {
sort.Sort(issues)
}
return issues, nil
}
// GetTrybotResults retrieves the trybot results for the given change from
// BuildBucket.
func (g *Gerrit) GetTrybotResults(ctx context.Context, issueID int64, patchsetID int64) ([]*buildbucketpb.Build, error) {
return g.BuildbucketClient.GetTrybotsForCL(ctx, issueID, patchsetID, g.baseUrl, nil)
}
// SetReadyForReview marks the change as ready for review (ie, not WIP).
func (g *Gerrit) SetReadyForReview(ctx context.Context, ci *ChangeInfo) error {
return g.post(ctx, fmt.Sprintf("/changes/%d/ready", ci.Issue), []byte("{}"))
}
var revisionRegex = regexp.MustCompile("^[a-z0-9]+$")
// Files returns the files that were modified, added or deleted in a revision.
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-files
func (g *Gerrit) Files(ctx context.Context, issue int64, patch string) (map[string]*FileInfo, error) {
if patch == "" {
patch = "current"
}
if !revisionRegex.MatchString(patch) {
return nil, skerr.Fmt("Invalid 'patch' value.")
}
url := fmt.Sprintf("/changes/%d/revisions/%s/files", issue, patch)
files := map[string]*FileInfo{}
if err := g.get(ctx, url, &files, ErrNotFound); err != nil {
return nil, skerr.Wrapf(err, "Failed to list files for issue %d", issue)
}
return files, nil
}
// GetFileNames returns the list of files for the given issue at the given patch. If
// patch is the empty string then the most recent patch is queried.
func (g *Gerrit) GetFileNames(ctx context.Context, issue int64, patch string) ([]string, error) {
files, err := g.Files(ctx, issue, patch)
if err != nil {
return nil, skerr.Wrap(err)
}
// We only need the filenames.
ret := []string{}
for filename := range files {
ret = append(ret, filename)
}
return ret, nil
}
// IsBinaryPatch returns true if the patch contains any binary files.
func (g *Gerrit) IsBinaryPatch(ctx context.Context, issue int64, revision string) (bool, error) {
files, err := g.Files(ctx, issue, revision)
if err != nil {
return false, skerr.Wrap(err)
}
for _, fileInfo := range files {
if fileInfo.Binary {
return true, nil
}
}
return false, nil
}
// Submit submits the Change.
func (g *Gerrit) Submit(ctx context.Context, ci *ChangeInfo) error {
return g.post(ctx, fmt.Sprintf("/changes/%s/submit", FullChangeId(ci)), []byte("{}"))
}
// The SubmittedTogetherInfo entity contains information about submitted
// together changes.
type SubmittedTogetherInfo struct {
Changes []*ChangeInfo `json:"changes"`
NonVisibleChanges int `json:"non_visible_changes"`
}
// SubmittedTogether returns list of all changes which are submitted when
// Submit is called for this change, including the current change itself.
// If the user calling the API does not have access to some changes then
// non_visible_changes will be > 0.
func (g *Gerrit) SubmittedTogether(ctx context.Context, ci *ChangeInfo) ([]*ChangeInfo, int, error) {
var submittedTogetherInfo *SubmittedTogetherInfo
if err := g.get(ctx, fmt.Sprintf("/changes/%s/submitted_together?o=NON_VISIBLE_CHANGES", FullChangeId(ci)), &submittedTogetherInfo, nil); err != nil {
return nil, -1, skerr.Wrapf(err, "Failed to retrieve submitted_together issues")
}
return submittedTogetherInfo.Changes, submittedTogetherInfo.NonVisibleChanges, nil
}
// DownloadCommitMsgHook downloads the commit message hook to the specified
// location.
func (g *Gerrit) DownloadCommitMsgHook(ctx context.Context, dest string) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
url := g.apiUrl + urlCommitMsgHook
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := g.doRequest(req)
if err != nil {
return skerr.Wrapf(err, "Failed to GET %s", url)
}
defer util.Close(resp.Body)
if err := util.WithWriteFile(dest, func(w io.Writer) error {
_, err := io.Copy(w, resp.Body)
return err
}); err != nil {
return err
}
return os.Chmod(dest, 0755)
}
// Rebase the given change onto the given optional base. If not provided, the
// change is rebased onto the target branch. If allowConflicts is true, the
// rebase succeeds even if there are conflicts, in which case the patch set will
// contain git conflict markers.
func (g *Gerrit) Rebase(ctx context.Context, ci *ChangeInfo, base string, allowConflicts bool) error {
postData := map[string]interface{}{
"base": base,
"allow_conflicts": allowConflicts,
}
return g.postJson(ctx, fmt.Sprintf("/changes/%s/rebase", FullChangeId(ci)), postData)
}
// CodeReviewCache is an LRU cache for Gerrit Issues that polls in the background to determine if
// issues have been updated. If so it expels 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)
}
// Get retrieves 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(context.Background(), 10000, true, 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
}
// CreateChange creates a new Change in the given project, based on a given Git commit or Gerrit change ID,
// on on the given branch, and with the given subject line.
func (g *Gerrit) CreateChange(ctx context.Context, project, branch, subject, baseCommit, baseChangeID string) (*ChangeInfo, error) {
if baseCommit != "" && baseChangeID != "" {
return nil, ErrBothChangeAndCommitID
}
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return nil, skerr.Wrap(err)
}
c := createChangePostData{
Project: project,
Branch: branch,
Subject: subject,
Status: "NEW",
BaseCommit: baseCommit,
BaseChange: baseChangeID,
}
b, err := json.Marshal(c)
if err != nil {
return nil, skerr.Wrap(err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.apiUrl+"/changes/", bytes.NewReader(b))
if err != nil {
return nil, skerr.Wrap(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.doRequest(req)
if err != nil {
return nil, skerr.Wrap(err)
}
defer util.Close(resp.Body)
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, skerr.Wrap(err)
}
if resp.StatusCode != http.StatusCreated {
return nil, skerr.Fmt("got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes))
}
var ci ChangeInfo
if err := json.NewDecoder(bytes.NewReader(respBytes[4:])).Decode(&ci); err != nil {
return nil, skerr.Wrap(err)
}
return fixupChangeInfo(&ci), nil
}
// CreateCherryPickChange will cherry-pick a revision into a destination branch.
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherry-pick
//
// - changeID: Identifier that uniquely identifies one change. It contains the
// URL-encoded project name as well as the change number: "'<project>~<changeNumber>'"
// - revisionID: Identifier that uniquely identifies one revision of a change.
// - msg: Text to be added as a commit message.
// - destBranch: The cherry-pick destination branch.
func (g *Gerrit) CreateCherryPickChange(ctx context.Context, changeID, revisionID, msg, destBranch string) (*ChangeInfo, error) {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return nil, skerr.Wrap(err)
}
c := cherryPickPostData{
Message: msg,
Destination: destBranch,
}
b, err := json.Marshal(c)
if err != nil {
return nil, skerr.Wrap(err)
}
url := fmt.Sprintf("%s/changes/%s/revisions/%s/cherrypick", g.apiUrl, changeID, revisionID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, skerr.Wrap(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.doRequest(req)
if err != nil {
return nil, skerr.Wrap(err)
}
defer util.Close(resp.Body)
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, skerr.Wrap(err)
}
if resp.StatusCode != http.StatusOK {
return nil, skerr.Fmt("got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes))
}
var ci ChangeInfo
if err := json.NewDecoder(bytes.NewReader(respBytes[4:])).Decode(&ci); err != nil {
return nil, skerr.Wrap(err)
}
return fixupChangeInfo(&ci), nil
}
// EditFile modifies the given file to have the given content. A ChangeEdit is created, if
// one is not already active. You must call PublishChangeEdit in order for the
// change to become a new patch set, otherwise it has no effect.
func (g *Gerrit) EditFile(ctx context.Context, ci *ChangeInfo, filepath, content string) error {
// Respect the rate limit.
if err := g.rl.Wait(ctx); err != nil {
return err
}
u := g.apiUrl + fmt.Sprintf("/changes/%s/edit/%s", ci.Id, url.QueryEscape(filepath))
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, strings.NewReader(content))
if err != nil {
return skerr.Wrapf(err, "Failed to create PUT request")
}
resp, err := g.doRequest(req)
if err != nil {
sklog.Infof("Attempted to write the following content to %s:\n%s", filepath, content)
// Maybe there is more information in the response body
if resp != nil {
defer util.Close(resp.Body)
if respBytes, err := io.ReadAll(resp.Body); err == nil {
sklog.Infof("Response to PUT %s was %s", u, string(respBytes))
}
}
return skerr.Wrapf(err, "Failed to execute EditFile request for file %s", filepath)
}
defer util.Close(resp.Body)
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return skerr.Wrapf(err, "Failed to read response body")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
return skerr.Fmt("Got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes))
}
return nil
}
// MoveFile moves a given file. A ChangeEdit is created, if one is not already active.
// You must call PublishChangeEdit in order for the change to become a new patch
// set, otherwise it has no effect.
func (g *Gerrit) MoveFile(ctx context.Context, ci *ChangeInfo, oldPath, newPath string) error {
data := struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
}{
OldPath: oldPath,
NewPath: newPath,
}
return g.postJson(ctx, fmt.Sprintf("/changes/%s/edit", ci.Id), data)
}
// DeleteFile deletes the given file. A ChangeEdit is created, if one is not already active.
// You must call PublishChangeEdit in order for the change to become a new patch
// set, otherwise it has no effect.
func (g *Gerrit) DeleteFile(ctx context.Context, ci *ChangeInfo, filepath string) error {
return g.delete(ctx, fmt.Sprintf("/changes/%s/edit/%s", ci.Id, url.QueryEscape(filepath)))
}
// DeleteVote deletes a single vote from a change. Documentation is here:
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#delete-vote
func (g *Gerrit) DeleteVote(ctx context.Context, changeNum int64, labelID string, accountID int, notify NotifyOption, ignoreAutomaticAttentionSetRules bool) error {
msg := struct {
Notify NotifyOption `json:"notify,omitempty"`
IgnoreAutomaticAttentionSetRules bool `json:"ignore_automatic_attention_set_rules,omitempty"`
}{
Notify: notify,
IgnoreAutomaticAttentionSetRules: ignoreAutomaticAttentionSetRules,
}
u := fmt.Sprintf("/changes/%d/reviewers/%d/votes/%s/delete", changeNum, accountID, labelID)
return g.postJson(ctx, u, msg)
}
// SetCommitMessage sets the commit message for the ChangeEdit. A ChangeEdit is created, if one is
// not already active. You must call PublishChangeEdit in order for the change
// to become a new patch set, otherwise it has no effect.
func (g *Gerrit) SetCommitMessage(ctx context.Context, ci *ChangeInfo, msg string) error {
m := struct {
Message string `json:"message"`
}{
Message: msg,
}
u := fmt.Sprintf("/changes/%s/edit:message", ci.Id)
return g.putJson(ctx, u, m)
}
// PublishChangeEdit publishes the active ChangeEdit as a new patch set.
func (g *Gerrit) PublishChangeEdit(ctx context.Context, ci *ChangeInfo) error {
msg := struct {
Notify string `json:"notify,omitempty"`
}{
Notify: "ALL",
}
u := fmt.Sprintf("/changes/%s/edit:publish", ci.Id)
return g.postJson(ctx, u, msg)
}
// DeleteChangeEdit deletes the active ChangeEdit, restoring the state to the last patch set.
func (g *Gerrit) DeleteChangeEdit(ctx context.Context, ci *ChangeInfo) error {
return g.delete(ctx, fmt.Sprintf("/changes/%s/edit", ci.Id))
}
// SetLabel sets the given label on the ChangeInfo.
func SetLabel(ci *ChangeInfo, key string, value int) {
labelEntry, ok := ci.Labels[key]
if !ok {
labelEntry = &LabelEntry{
All: []*LabelDetail{},
}
ci.Labels[key] = labelEntry
}
labelEntry.All = append(labelEntry.All, &LabelDetail{
Value: value,
})
}
// SetLabels sets the given labels on the ChangeInfo.
func SetLabels(ci *ChangeInfo, labels map[string]int) {
for key, value := range labels {
SetLabel(ci, key, value)
}
}
// UnsetLabel unsets the given label on the ChangeInfo.
func UnsetLabel(ci *ChangeInfo, key string, value int) {
labelEntry, ok := ci.Labels[key]
if !ok {
return
}
newEntries := make([]*LabelDetail, 0, len(labelEntry.All))
for _, details := range labelEntry.All {
if details.Value != value {
newEntries = append(newEntries, details)
}
}
labelEntry.All = newEntries
}
// UnsetLabels unsets the given labels on the ChangeInfo.
func UnsetLabels(ci *ChangeInfo, labels map[string]int) {
for key, value := range labels {
UnsetLabel(ci, key, value)
}
}
// ParseChangeId parses the change ID out of the given commit message.
func ParseChangeId(msg string) (string, error) {
for _, line := range strings.Split(msg, "\n") {
m := changeIdRegex.FindStringSubmatch(line)
if m != nil && len(m) == 2 {
return m[1], nil
}
}
return "", skerr.Fmt("Failed to parse Change-Id from commit message.")
}
// FullChangeId returns the most specific representation of the specified
// change. See
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id
func FullChangeId(ci *ChangeInfo) string {
project := ci.Project
// Encode the project to convert names like chromium/src into chromium%2Fsrc.
project = url.QueryEscape(project)
return fmt.Sprintf("%s~%d", project, ci.Issue)
}
// SetTraceIDPrefix enables tracing for all requests, with the given prefix.
// The full trace ID consists of the prefix and the timestamp of the request.
// If an empty string is provided, tracing is disabled. It is recommended to use
// an issue number for the trace ID, eg. "issue/123"
func (g *Gerrit) SetTraceIDPrefix(traceIdPrefix string) {
g.traceIdPrefix = traceIdPrefix
}
// doRequest executes the given http.Request. It is a thin wrapper around
// g.client.Do().
func (g *Gerrit) doRequest(req *http.Request) (*http.Response, error) {
var traceID string
if g.traceIdPrefix != "" {
traceID = fmt.Sprintf("%s-%d", g.traceIdPrefix, time.Now().UnixNano())
req.Header.Add(HeaderTracing, traceID)
}
resp, err := g.client.Do(req)
if traceID != "" {
err = skerr.Wrapf(err, "trace ID %q", traceID)
}
return resp, skerr.Wrap(err)
}