blob: 818a6b581529179a3ac5a1781e99036c96b18c87 [file] [log] [blame]
// Copyright 2020 The LUCI 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 gerritfake
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
gerritutil "go.chromium.org/luci/common/api/gerrit"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/clock/testclock"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
gerritpb "go.chromium.org/luci/common/proto/gerrit"
"go.chromium.org/luci/cv/internal/gerrit"
)
// Client implements client for Fake Gerrit.
type Client struct {
f *Fake
luciProject string // used in ACL checks.
host string
}
var _ gerrit.Client = (*Client)(nil)
///////////////////////////////////////////////////////////////////////////////
// Read RPCs
// Lists changes that match a query.
//
// Note, although the Gerrit API supports multiple queries, for which
// it can return multiple lists of changes, this is not a foreseen use-case
// so this API just includes one query with one returned list of changes.
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
func (client *Client) ListChanges(ctx context.Context, in *gerritpb.ListChangesRequest, opts ...grpc.CallOption) (*gerritpb.ListChangesResponse, error) {
client.f.recordRequest(in)
if in.GetOffset() != 0 {
return nil, status.New(codes.Unimplemented, "Offset is not supported by GerritFake").Err()
}
q, err := parseListChangesQuery(in.GetQuery())
if err != nil {
return nil, status.New(codes.InvalidArgument, err.Error()).Err()
}
client.f.m.Lock()
defer client.f.m.Unlock()
changes := make([]*gerritpb.ChangeInfo, 0, len(client.f.cs))
for _, ch := range client.f.cs {
switch {
case ch.Host != client.host:
case ch.ACLs(OpRead, client.luciProject).Code() != codes.OK:
case !q.matches(ch):
default:
changes = append(changes, applyChangeOpts(ch, in.GetOptions()))
}
}
// Sort from the most recently to least recently updated,
// and if equal, deterministically disambiguate on change number to avoid
// flaky tests.
sort.Slice(changes, func(i, j int) bool {
l := changes[i].GetUpdated().AsTime()
r := changes[j].GetUpdated().AsTime()
switch {
case l.Before(r):
return false
case l.After(r):
return true
default:
return changes[i].GetNumber() > changes[j].GetNumber()
}
})
res := &gerritpb.ListChangesResponse{Changes: changes}
if in.GetLimit() > 0 && int64(len(changes)) > in.GetLimit() {
res.Changes = changes[:in.GetLimit()]
res.MoreChanges = true
}
return res, nil
}
// Loads a change by id.
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-change
func (client *Client) GetChange(ctx context.Context, in *gerritpb.GetChangeRequest, opts ...grpc.CallOption) (*gerritpb.ChangeInfo, error) {
client.f.m.Lock()
defer client.f.m.Unlock()
client.f.recordRequest(in)
change, err := client.getChangeEnforceACLsLocked(in.GetNumber())
if err != nil {
return nil, err
}
return applyChangeOpts(change, in.GetOptions()), nil
}
// Retrieves related changes of a revision.
//
// Related changes are changes that either depend on, or are dependencies of
// the revision.
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-related-changes
func (client *Client) GetRelatedChanges(ctx context.Context, in *gerritpb.GetRelatedChangesRequest, opts ...grpc.CallOption) (*gerritpb.GetRelatedChangesResponse, error) {
client.f.m.Lock()
defer client.f.m.Unlock()
client.f.recordRequest(in)
change, err := client.getChangeEnforceACLsLocked(in.GetNumber())
if err != nil {
return nil, err
}
ps, _, err := change.resolveRevision(in.GetRevisionId())
if err != nil {
return nil, err
}
start := psKey(client.host, int(in.GetNumber()), ps)
res := &gerritpb.GetRelatedChangesResponse{}
added := stringset.New(10)
add := func(psk string) error {
if !added.Add(psk) {
return nil
}
change, rev, ri, err := client.f.resolvePSKeyLocked(psk)
if err != nil {
return err
}
cc := &gerritpb.GetRelatedChangesResponse_ChangeAndCommit{
Project: change.Info.GetProject(),
Number: change.Info.GetNumber(),
Patchset: int64(ri.GetNumber()),
CurrentPatchset: int64(change.Info.GetRevisions()[change.Info.GetCurrentRevision()].GetNumber()),
Commit: &gerritpb.CommitInfo{Id: rev},
}
for _, parentPSkey := range client.f.parentsOf[psk] {
_, parentRev, _, err := client.f.resolvePSKeyLocked(parentPSkey)
if err != nil {
return err
}
cc.GetCommit().Parents = append(cc.GetCommit().Parents, &gerritpb.CommitInfo_Parent{Id: parentRev})
}
res.Changes = append(res.Changes, cc)
return nil
}
// NOTE: Gerrit actually guarantees specific order. For simplicity, this fake
// doesn't. We just recurse in direction of both child->parent and
// parent->child and add visited changes to the list.
if err := visitNodesDFS(start, client.f.childrenOf, add); err != nil {
return nil, err
}
if err := visitNodesDFS(start, client.f.parentsOf, add); err != nil {
return nil, err
}
if len(res.GetChanges()) == 1 {
// Just the starting change itself, emulate Gerrit by returning empty list.
res.Changes = nil
}
return res, nil
}
// Lists the files that were modified, added or deleted in a revision.
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-files
func (client *Client) ListFiles(ctx context.Context, in *gerritpb.ListFilesRequest, opts ...grpc.CallOption) (*gerritpb.ListFilesResponse, error) {
client.f.m.Lock()
defer client.f.m.Unlock()
client.f.recordRequest(in)
change, err := client.getChangeEnforceACLsLocked(in.GetNumber())
if err != nil {
return nil, err
}
_, ri, err := change.resolveRevision(in.GetRevisionId())
if err != nil {
return nil, err
}
// Note: for simplicity of fake, use files inside a revision, even though it
// differs from what ListFiles may return for merge commit in Gerrit.
ret := &gerritpb.ListFilesResponse{}
// Deep copy before returning.
proto.Merge(ret, &gerritpb.ListFilesResponse{Files: ri.GetFiles()})
return ret, nil
}
///////////////////////////////////////////////////////////////////////////////
// Write RPCs
// Set various review bits on a change.
//
// Currently, only support following functionalities:
// - Post Message.
// - Set votes on a label (by project itself or on behalf of other user)
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-review
func (client *Client) SetReview(ctx context.Context, in *gerritpb.SetReviewRequest, opts ...grpc.CallOption) (*gerritpb.ReviewResult, error) {
client.f.m.Lock()
defer client.f.m.Unlock()
client.f.recordRequest(in)
ch, found := client.f.cs[key(client.host, int(in.GetNumber()))]
if !found {
return nil, status.Errorf(codes.NotFound, "change %s/%d not found", client.host, in.GetNumber())
}
if err := client.setReviewEnforceACLs(in, ch); err != nil {
return nil, err
}
ch.Info.Updated = calcUpdatedTime(ctx, ch.Info.GetUpdated())
now := clock.Now(ctx).UTC()
if in.Message != "" {
ch.Info.Messages = append(ch.Info.Messages, &gerritpb.ChangeMessageInfo{
Id: strconv.Itoa(len(ch.Info.Messages)),
Author: U(client.luciProject),
Date: timestamppb.New(now),
Message: in.Message,
})
}
if len(in.Labels) > 0 {
for label, val := range in.Labels {
if in.OnBehalfOf == 0 {
Vote(label, int(val), now, U(client.luciProject))(ch.Info)
} else {
Vote(label, int(val), now, U(fmt.Sprintf("user-%d", in.OnBehalfOf)))(ch.Info)
}
}
}
return &gerritpb.ReviewResult{Labels: in.GetLabels()}, nil
}
// Submit a specific revision of a change.
//
// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-revision
func (client *Client) SubmitRevision(ctx context.Context, in *gerritpb.SubmitRevisionRequest, opts ...grpc.CallOption) (*gerritpb.SubmitInfo, error) {
client.f.m.Lock()
defer client.f.m.Unlock()
client.f.recordRequest(in)
ch, found := client.f.cs[key(client.host, int(in.GetNumber()))]
if !found {
return nil, status.Errorf(codes.NotFound, "change %s/%d not found", client.host, in.GetNumber())
}
if status := ch.ACLs(OpSubmit, client.luciProject); status.Code() != codes.OK {
return nil, status.Err()
}
rev := in.GetRevisionId()
if _, ok := ch.Info.GetRevisions()[rev]; !ok {
return nil, status.Errorf(codes.NotFound, "revision %s not found", rev)
}
if rev != ch.Info.GetCurrentRevision() {
return nil, status.Errorf(codes.FailedPrecondition, "revision %s is not current revision", rev)
}
switch ch.Info.GetStatus() {
case gerritpb.ChangeStatus_NEW:
ch.Info.Status = gerritpb.ChangeStatus_MERGED
// Most projects use a submit strategy which always creates a new patchset.
// simulate the behavior here.
PS(int(ch.Info.GetRevisions()[rev].GetNumber() + 1))(ch.Info)
ch.Info.Updated = calcUpdatedTime(ctx, ch.Info.Updated)
return &gerritpb.SubmitInfo{Status: gerritpb.ChangeStatus_MERGED}, nil
case gerritpb.ChangeStatus_MERGED:
return nil, status.Errorf(codes.FailedPrecondition, "change is merged")
case gerritpb.ChangeStatus_ABANDONED:
return nil, status.Errorf(codes.FailedPrecondition, "change is abandoned")
default:
panic(fmt.Errorf("unrecognized status %s", ch.Info.GetStatus()))
}
}
///////////////////////////////////////////////////////////////////////////////
// Helper methods
// visitNodesDFS visits all nodes reachable from the current node via depth
// first search.
//
// Calls clbk for each node visited. If clbk returns error, visitNodesDFS aborts
// immediatey and returns the same error.
func visitNodesDFS(node string, edges map[string][]string, clbk func(node string) error) error {
visited := stringset.New(1)
var visit func(n string) error
visit = func(n string) error {
if !visited.Add(n) {
return nil
}
for _, m := range edges[n] {
if err := visit(m); err != nil {
return err
}
}
return clbk(n)
}
return visit(node)
}
func (client *Client) getChangeEnforceACLsLocked(change int64) (*Change, error) {
ch, found := client.f.cs[key(client.host, int(change))]
if !found {
return nil, status.Errorf(codes.NotFound, "change %s/%d not found", client.host, change)
}
if status := ch.ACLs(OpRead, client.luciProject); status.Code() != codes.OK {
return nil, status.Err()
}
return ch, nil
}
func (client *Client) setReviewEnforceACLs(in *gerritpb.SetReviewRequest, ch *Change) error {
if in.Message != "" {
if status := ch.ACLs(OpReview, client.luciProject); status.Code() != codes.OK {
return status.Err()
}
}
if len(in.Labels) > 0 {
if in.OnBehalfOf == 0 {
if status := ch.ACLs(OpReview, client.luciProject); status.Code() != codes.OK {
return status.Err()
}
} else {
if status := ch.ACLs(OpAlterVotesOfOthers, client.luciProject); status.Code() != codes.OK {
return status.Err()
}
}
}
return nil
}
func applyChangeOpts(change *Change, opts []gerritpb.QueryOption) *gerritpb.ChangeInfo {
qopts := make(map[gerritpb.QueryOption]struct{}, len(opts))
for _, qopt := range opts {
qopts[qopt] = struct{}{}
}
has := func(o gerritpb.QueryOption) bool {
_, yes := qopts[o]
return yes
}
// First, deep copy.
ci := &gerritpb.ChangeInfo{}
proto.Merge(ci, change.Info)
// Second, mutate obeying query options.
// TODO(tandrii): support more options as needed.
switch {
case has(gerritpb.QueryOption_ALL_REVISIONS):
// Nothing to remove.
case has(gerritpb.QueryOption_CURRENT_REVISION):
// Remove all but current.
for rev := range ci.GetRevisions() {
if rev != ci.GetCurrentRevision() {
delete(ci.GetRevisions(), rev)
}
}
default:
ci.CurrentRevision = "" // Yeah, weirdly, Gerrit doesn't set this unconditionally.
ci.Revisions = nil
}
return ci
}
// calcUpdatedTime always push Updated time forward.
func calcUpdatedTime(ctx context.Context, curUpdatedTime *timestamppb.Timestamp) *timestamppb.Timestamp {
now := clock.Now(ctx).UTC() // UTC is for easy to read logs
switch u := curUpdatedTime.AsTime(); {
case now.Before(u):
panic(fmt.Errorf("clock's time [%s] is before the Updated time [%s]", now, u))
case u.Equal(now):
if tclock, ok := clock.Get(ctx).(testclock.TestClock); ok {
logging.Debugf(ctx, "testclock.Time += 1second to ensure increasing Updated time")
tclock.Add(time.Second)
return timestamppb.New(tclock.Now())
}
panic(fmt.Errorf("clock's time [%s] is equal to the Updated time [%s] and not running in test", now, u))
}
return timestamppb.New(now)
}
type parsedListChangesQuery struct {
after time.Time
before time.Time
status gerritpb.ChangeStatus
projectPrefix string
projects stringset.Set
label struct {
name string
minValueExclusive int
}
}
// parseListChangesQuery parses ListChangesRequest.Query for CV needs.
//
// It has lots of shortcomings:
// * silently allows to repeat and overwrite prior instance of predicate,
// e.g. "status:new status:merged" is treated as "status:merged".
// * restricts (.. OR .. ) clauses only to project: predicates
// * doesn't support OR without ()
// * and many others.
//
// TODO(tandrii): this should be replaced by a proper library solution,
// perhaps the only implementing parsing & evaluation of https://aip.dev/160
// filtering proposal, which should suffice.
func parseListChangesQuery(query string) (p *parsedListChangesQuery, err error) {
defer func() {
if err != nil {
err = errors.Annotate(err, "invalid query argument %q", query).Err()
p = nil
}
}()
mustUnquote := func(quoted string) string {
l := len(quoted)
if l <= 2 || quoted[0] != '"' || quoted[l-1] != '"' {
err = errors.Reason("expected quoted string, but got %q", quoted).Err()
}
return quoted[1 : l-1]
}
inClause := false
mustBeInClause := func(tok string) {
if !inClause {
err = errors.Reason("%q must be inside ()", tok).Err()
}
}
mustBeOutClause := func(tok string) {
if inClause {
err = errors.Reason("%q must be outside of ()", tok).Err()
}
}
p = &parsedListChangesQuery{}
tokenizer := queryTokenizer{query}
for {
switch tok := tokenizer.next(); tok {
case "":
mustBeOutClause(tok)
return
case "(":
mustBeOutClause(tok)
inClause = true
case ")":
mustBeInClause(tok)
inClause = false
case "OR":
mustBeInClause(tok)
// TODO(tandrii): check for duplicate predicates here and below.
case "project:":
if p.projects.Len() > 0 {
mustBeInClause(tok)
} else {
p.projects = stringset.New(1)
}
p.projects.Add(mustUnquote(tokenizer.next()))
case "projects:":
mustBeOutClause(tok)
p.projectPrefix = mustUnquote(tokenizer.next())
case "after:":
mustBeOutClause(tok)
// gerritutil.ParseTime checks quotes.
p.after, err = gerritutil.ParseTime(tokenizer.next())
case "before:":
mustBeOutClause(tok)
p.before, err = gerritutil.ParseTime(tokenizer.next())
case "status:":
mustBeOutClause(tok)
tok = tokenizer.next()
if v, ok := gerritpb.ChangeStatus_value[strings.ToUpper(tok)]; !ok {
err = errors.Reason("unrecognized status %q", tok).Err()
} else {
p.status = gerritpb.ChangeStatus(v)
}
case "label:":
tok = tokenizer.next()
switch parts := strings.SplitN(tok, ">", 2); {
case len(parts) != 2 || parts[0] == "" || parts[1] == "":
err = errors.Reason("invalid label: %s", tok).Err()
default:
p.label.name = parts[0]
p.label.minValueExclusive, err = strconv.Atoi(parts[1])
}
default:
err = errors.Reason("unrecognized token %q", tok).Err()
}
if err != nil {
return
}
}
}
func (p *parsedListChangesQuery) matches(c *Change) bool {
switch {
// after/before are inclusive in Gerrit.
case !p.after.IsZero() && p.after.After(c.Info.GetUpdated().AsTime()):
case !p.before.IsZero() && p.before.Before(c.Info.GetUpdated().AsTime()):
case p.projects.Len() > 0 && !p.projects.Has(c.Info.GetProject()):
case p.projectPrefix != "" && !strings.HasPrefix(c.Info.GetProject(), p.projectPrefix):
case p.status != gerritpb.ChangeStatus_CHANGE_STATUS_INVALID && c.Info.GetStatus() != p.status:
case !p.matchesLabel(c):
default:
return true
}
return false
}
func (p *parsedListChangesQuery) matchesLabel(c *Change) bool {
switch li, exists := c.Info.GetLabels()[p.label.name]; {
case p.label.name == "":
return true
case !exists:
return false
default:
// In theory, we could use aggregated `li.GetValue()`, but this requires all
// ChangeInfos to be faked correctly.
for _, vote := range li.GetAll() {
if vote.GetValue() > int32(p.label.minValueExclusive) {
return true
}
}
return false
}
}
type queryTokenizer struct {
remaining string
}
func (q *queryTokenizer) next() (token string) {
consume := func(l int) {
token, q.remaining = q.remaining[:l], q.remaining[l:]
}
q.remaining = strings.TrimLeft(q.remaining, " ")
switch {
case q.remaining == "":
case q.remaining[0] == '(' || q.remaining[0] == ')':
consume(1)
case q.remaining[0] == '"':
if endQuote := strings.IndexRune(q.remaining[1:], '"'); endQuote == -1 {
// No matching closing ", so consume the rest of the string.
consume(len(q.remaining))
} else {
consume(1 + endQuote + 1)
}
case len(q.remaining) == 1:
consume(1)
case q.remaining[:2] == "OR":
consume(2)
default:
for i, c := range q.remaining {
switch c {
case ':':
consume(i + 1)
return
case ' ':
consume(i)
return
}
}
consume(len(q.remaining))
}
return
}