blob: 2848af32d372a52008f29d79cec3727ffe7993d1 [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"
"regexp"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/common/clock/testclock"
"go.chromium.org/luci/common/data/stringset"
gerritpb "go.chromium.org/luci/common/proto/gerrit"
"go.chromium.org/luci/cv/internal/gerrit"
)
// Fake simulates Gerrit for CV tests.
type Fake struct {
// m protects all other members below.
m sync.Mutex
// cs is a set of changes, indexed by (host, change number).
// See key() function.
cs map[string]*Change
// parentsOf maps a change's patchset (host, change number, patchset)
// to one or more Git parents; each parent is another change's patchset.
//
// parentsOf[X] can be read as "changes on which X depends non-transitively".
//
// parentsOf is essentially the DAG (directed acyclic graph) that Git stores.
parentsOf map[string][]string
// childrenOf is a reverse of parentsOf.
//
// childrenOf[X] can be read as "changes which depend on X non-transitively".
childrenOf map[string][]string
// requests are all incoming requests that this Fake has received.
requests []proto.Message
requestsMu sync.RWMutex
}
// MakeClient implemnents gerrit.Factory.
func (f *Fake) MakeClient(ctx context.Context, gerritHost, luciProject string) (gerrit.Client, error) {
if strings.ContainsRune(luciProject, '.') {
// Quickly catch common mistake.
panic(fmt.Errorf("wrong gerritHost or luciProject: %q %q", gerritHost, luciProject))
}
return &Client{f: f, luciProject: luciProject, host: gerritHost}, nil
}
// MakeMirrorIterator implemnents gerrit.Factory.
func (f *Fake) MakeMirrorIterator(ctx context.Context) *gerrit.MirrorIterator {
return &gerrit.MirrorIterator{""}
}
// Requests returns a shallow copy of all incoming requests this fake has
// received.
func (f *Fake) Requests() []proto.Message {
f.requestsMu.RLock()
defer f.requestsMu.RUnlock()
cpy := make([]proto.Message, len(f.requests))
copy(cpy, f.requests)
return cpy
}
func (f *Fake) recordRequest(req proto.Message) {
f.requestsMu.Lock()
defer f.requestsMu.Unlock()
f.requests = append(f.requests, proto.Clone(req))
}
// Change = change details + ACLs.
type Change struct {
Host string
Info *gerritpb.ChangeInfo
ACLs AccessCheck
}
// Copy deep-copies a Change.
// NOTE: ACLs, which is a reference to a func, isn't deep-copied.
func (c *Change) Copy() *Change {
r := &Change{
Host: c.Host,
Info: proto.Clone(c.Info).(*gerritpb.ChangeInfo),
ACLs: c.ACLs,
}
return r
}
type AccessCheck func(op Operation, luciProject string) *status.Status
type Operation int
const (
// OpRead gates Fetch CL metadata, files, related CLs.
OpRead Operation = iota
// OpReview gates posting comments and votes on one's own behalf.
//
// NOTE: The actual Gerrit service has per-label ACLs for voting, but CV
// doesn't vote on its own.
OpReview
// OpAlterVotesOfOthers gates altering votes of behalf of others.
OpAlterVotesOfOthers
// OpSubmit gates submitting.
OpSubmit
)
///////////////////////////////////////////////////////////////////////////////
// Antiboilerplate functions to reduce verbosity in tests.
// WithCLs returns Fake with several changes.
func WithCLs(cs ...*Change) *Fake {
f := &Fake{
cs: make(map[string]*Change, len(cs)),
}
for _, c := range cs {
cpy := &Change{
Host: c.Host,
ACLs: c.ACLs,
Info: &gerritpb.ChangeInfo{},
}
proto.Merge(cpy.Info, c.Info)
f.cs[c.key()] = cpy
}
return f
}
// WithCIs returns a Fake with one change per passed ChangeInfo sharing the same
// host and ACLs.
func WithCIs(host string, acls AccessCheck, cis ...*gerritpb.ChangeInfo) *Fake {
f := &Fake{}
f.cs = make(map[string]*Change, len(cis))
for _, ci := range cis {
c := &Change{
Host: host,
ACLs: acls,
Info: &gerritpb.ChangeInfo{},
}
proto.Merge(c.Info, ci)
f.cs[c.key()] = c
}
return f
}
// AddFrom adds all changes from another fake to the this fake and returns this
// fake.
//
// Changes are added by reference. Primarily useful to construct Fake with CLs
// on several hosts, e.g.:
// fake := WithCIs(hostA, aclA, ciA1, ciA2).AddFrom(hostB, aclB, ciB1)
func (f *Fake) AddFrom(other *Fake) *Fake {
f.m.Lock()
defer f.m.Unlock()
other.m.Lock()
defer other.m.Unlock()
if f.cs == nil {
f.cs = make(map[string]*Change, len(other.cs))
}
for k, c := range other.cs {
if f.cs[k] != nil {
panic(fmt.Errorf("change %s defined in both fakes", k))
}
f.cs[k] = c
}
if f.childrenOf == nil {
f.childrenOf = make(map[string][]string, len(other.childrenOf))
}
for k, vs := range other.childrenOf {
f.childrenOf[k] = append(f.childrenOf[k], vs...)
}
if f.parentsOf == nil {
f.parentsOf = make(map[string][]string, len(other.parentsOf))
}
for k, vs := range other.parentsOf {
f.parentsOf[k] = append(f.parentsOf[k], vs...)
}
return f
}
type CIModifier func(ci *gerritpb.ChangeInfo)
// CI creates a new ChangeInfo with 1 patchset with status NEW and without any
// votes.
func CI(change int, mods ...CIModifier) *gerritpb.ChangeInfo {
rev := Rev(change, 1)
ci := &gerritpb.ChangeInfo{
Number: int64(change),
Project: "infra/infra",
Ref: "refs/heads/main",
Status: gerritpb.ChangeStatus_NEW,
Owner: U("owner-99"),
Created: timestamppb.New(testclock.TestRecentTimeUTC.Add(1 * time.Hour)),
Updated: timestamppb.New(testclock.TestRecentTimeUTC.Add(2 * time.Hour)),
CurrentRevision: rev,
Revisions: map[string]*gerritpb.RevisionInfo{
rev: RevInfo(1),
},
}
for _, m := range mods {
m(ci)
}
return ci
}
func RevInfo(ps int) *gerritpb.RevisionInfo {
return &gerritpb.RevisionInfo{
Number: int32(ps),
Kind: gerritpb.RevisionInfo_REWORK,
Created: timestamppb.New(testclock.TestRecentTimeUTC.Add(1 * time.Hour).Add(time.Duration(ps) * time.Minute)),
Files: map[string]*gerritpb.FileInfo{
fmt.Sprintf("ps%03d/c.cpp", ps): {Status: gerritpb.FileInfo_W},
"shared/s.py": {Status: gerritpb.FileInfo_W},
},
Commit: &gerritpb.CommitInfo{
Id: "", // Id isn't set by Gerrit. It's set as a key in the revisions map.
Parents: []*gerritpb.CommitInfo_Parent{
{Id: "fake_parent_commit"},
},
Message: "Commit.\n\nDescription.",
},
}
}
// Rev generates revision in the form "rev-000006-013" where 6 and 13 are change and
// patchset numbers, respectively.
func Rev(ch, ps int) string {
return fmt.Sprintf("rev-%06d-%03d", ch, ps)
}
// RelatedChange returns ChangeAndCommit for the GetRelatedChangesResponse.
//
// Parents can be specified in several ways:
// * gerritpb.CommitInfo_Parent
// * gerritpb.CommitInfo
// * "<change>_<patchset>", e.g. "123_4"
// * "<revision>" (without underscores).
func RelatedChange(change, ps, curPs int, parents ...interface{}) *gerritpb.GetRelatedChangesResponse_ChangeAndCommit {
prs := make([]*gerritpb.CommitInfo_Parent, len(parents))
for i, pi := range parents {
switch v := pi.(type) {
case *gerritpb.CommitInfo_Parent:
prs[i] = v
case *gerritpb.CommitInfo:
prs[i] = &gerritpb.CommitInfo_Parent{Id: v.GetId()}
case string:
if j := strings.IndexRune(v, '_'); j != -1 {
prs[i] = &gerritpb.CommitInfo_Parent{Id: Rev(atoi(v[:j]), atoi(v[j+1:]))}
} else {
prs[i] = &gerritpb.CommitInfo_Parent{Id: v}
}
default:
panic(fmt.Errorf("unsupported type %T as commit parent #%d", pi, i))
}
}
return &gerritpb.GetRelatedChangesResponse_ChangeAndCommit{
CurrentPatchset: int64(curPs),
Number: int64(change),
Patchset: int64(ps),
Commit: &gerritpb.CommitInfo{
Id: Rev(change, ps),
Parents: prs,
},
}
}
// ACLRestricted grants full access to specified projects only.
func ACLRestricted(luciProjects ...string) AccessCheck {
ps := stringset.NewFromSlice(luciProjects...)
return func(_ Operation, luciProject string) *status.Status {
if ps.Has(luciProject) {
return status.New(codes.OK, "")
}
return status.New(codes.NotFound, "")
}
}
// ACLPublic grants what every registered user can do on public projects.
func ACLPublic() AccessCheck {
return func(op Operation, _ string) *status.Status {
switch op {
case OpRead, OpReview:
return status.New(codes.OK, "")
default:
return status.New(codes.PermissionDenied, "can read, can't modify")
}
}
}
// ACLReadOnly grants read-only access to the given projects.
func ACLReadOnly(luciProjects ...string) AccessCheck {
ps := stringset.NewFromSlice(luciProjects...)
return func(op Operation, p string) *status.Status {
switch {
case !ps.Has(p):
return status.New(codes.NotFound, "")
case op == OpRead:
return status.New(codes.OK, "")
default:
return status.New(codes.PermissionDenied, "can read, can't modify")
}
}
}
// ACLGrant grants a permission to given projects.
func ACLGrant(op Operation, code codes.Code, luciProjects ...string) AccessCheck {
ps := stringset.NewFromSlice(luciProjects...)
return func(o Operation, p string) *status.Status {
if ps.Has(p) && o == op {
return status.New(codes.OK, "")
}
return status.New(code, "")
}
}
// Or returns the "less restrictive" status of the 2+ AccessChecks.
//
// {OK, FAILED_PRECONDITION} <= PERMISSION_DENIED <= NOT_FOUND.
// Doesn't work well with other statuses.
func (a AccessCheck) Or(bs ...AccessCheck) AccessCheck {
return func(op Operation, luciProject string) *status.Status {
ret := a(op, luciProject)
switch ret.Code() {
case codes.OK, codes.FailedPrecondition:
return ret
}
for _, b := range bs {
s := b(op, luciProject)
switch s.Code() {
case codes.OK, codes.FailedPrecondition:
return s
case codes.PermissionDenied:
ret = s
}
}
return ret
}
}
///////////////////////////////////////////////////////////////////////////////
// CI Modifiers
// PS ensures ChangeInfo's CurrentRevision corresponds to given patchset,
// and deletes all revisions with bigger patchsets.
func PS(ps int) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
var toDelete []string
found := false
for rev, ri := range ci.GetRevisions() {
switch latest := int(ri.GetNumber()); {
case latest == ps:
ci.CurrentRevision = rev
found = true
case latest > ps:
toDelete = append(toDelete, rev)
}
}
for _, rev := range toDelete {
delete(ci.GetRevisions(), rev)
}
if !found {
rev := Rev(int(ci.GetNumber()), ps)
ci.CurrentRevision = rev
ci.GetRevisions()[rev] = RevInfo(int(ps))
}
}
}
// AllRevs ensures ChangeInfo has a RevisionInfo per each revision
// corresponding to patchsets 1..current.
func AllRevs() CIModifier {
return func(ci *gerritpb.ChangeInfo) {
max := int(ci.GetRevisions()[ci.GetCurrentRevision()].GetNumber())
found := make([]bool, max)
for _, ri := range ci.GetRevisions() {
found[ri.GetNumber()-1] = true
}
for i, f := range found {
if !f {
ps := i + 1
ci.GetRevisions()[Rev(int(ci.GetNumber()), ps)] = RevInfo(ps)
}
}
}
}
// Files sets ChangeInfo's current revision to contain given files.
func Files(fs ...string) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ri := ci.GetRevisions()[ci.GetCurrentRevision()]
m := make(map[string]*gerritpb.FileInfo, len(fs))
for _, f := range fs {
// CV doesn't actually care what status is.
m[f] = &gerritpb.FileInfo{}
}
ri.Files = m
}
}
// Desc sets commit message, aka CL description, for ChangeInfo's current
// revision.
func Desc(cldescription string) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ri := ci.GetRevisions()[ci.GetCurrentRevision()]
ri.GetCommit().Message = cldescription
}
}
// Owner sets .Owner to the given username.
//
// See U() for format.
func Owner(username string) CIModifier {
a := U(username) // fail fast if wrong format
return func(ci *gerritpb.ChangeInfo) {
ci.Owner = a
}
}
// Updated sets .Updated to the given time.
func Updated(t time.Time) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ci.Updated = timestamppb.New(t)
}
}
// Ref sets .Ref to the given ref.
func Ref(ref string) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
if !strings.HasPrefix(ref, "refs/") {
panic(fmt.Errorf("ref must start with 'refs/', but %q given", ref))
}
ci.Ref = ref
}
}
// Project sets .Project to the given Gerrit project.
func Project(p string) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ci.Project = p
}
}
// Status sets .Status to the given status.
// Either a string or value of gerritpb.ChangeStatus.
func Status(s interface{}) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
switch v := s.(type) {
case gerritpb.ChangeStatus:
ci.Status = v
return
case string:
if i, exists := gerritpb.ChangeStatus_value[v]; exists {
ci.Status = gerritpb.ChangeStatus(i)
return
}
}
panic(fmt.Errorf("unrecognized status %v", s))
}
}
// Messages sets .Messages to the given messages.
func Messages(msgs ...*gerritpb.ChangeMessageInfo) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ci.Messages = msgs
}
}
// Vote sets a label to the given value by the given user(s) on the latest
// patchset.
func Vote(label string, value int, timeAndUser ...interface{}) CIModifier {
var who *gerritpb.AccountInfo
var when time.Time
switch {
case len(timeAndUser) == 0:
// Larger than default rev creation time even with lots of patchsets.
when = testclock.TestRecentTimeUTC.Add(10 * time.Hour)
who = U("user-1")
case len(timeAndUser) != 2:
panic(fmt.Errorf("incorrect usage, must have 2 params, not %d", len(timeAndUser)))
default:
var ok bool
if when, ok = timeAndUser[0].(time.Time); !ok {
panic(fmt.Errorf("expected time.Time, got %T", timeAndUser[0]))
}
switch v := timeAndUser[1].(type) {
case *gerritpb.AccountInfo:
who = v
case string:
who = U(v)
default:
panic(fmt.Errorf("expected *gerritpb.AccountInfo or string, got %T", v))
}
}
ai := &gerritpb.ApprovalInfo{
User: who,
Date: timestamppb.New(when),
Value: int32(value),
}
return func(ci *gerritpb.ChangeInfo) {
if ci.GetLabels() == nil {
ci.Labels = map[string]*gerritpb.LabelInfo{}
}
switch li, ok := ci.GetLabels()[label]; {
case !ok:
ci.GetLabels()[label] = &gerritpb.LabelInfo{
All: []*gerritpb.ApprovalInfo{ai},
}
case ok:
for i, existing := range li.GetAll() {
if existing.GetUser().GetAccountId() == ai.GetUser().GetAccountId() {
li.All[i] = ai
return
}
}
li.All = append(li.GetAll(), ai)
}
}
}
// CQ is a shorthand for Vote("Commit-Queue", ...).
func CQ(value int, timeAndUser ...interface{}) CIModifier {
return Vote("Commit-Queue", value, timeAndUser...)
}
// Approve sets Submittable to true.
func Approve() CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ci.Submittable = true
}
}
// Disapprove sets Submittable to false.
func Disapprove() CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ci.Submittable = false
}
}
// Reviewer sets the reviewers of the CL.
func Reviewer(rs ...*gerritpb.AccountInfo) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
if ci.Reviewers == nil {
ci.Reviewers = &gerritpb.ReviewerStatusMap{}
}
ci.Reviewers.Reviewers = rs
}
}
var usernameToAccountIDRegexp = regexp.MustCompile(`^.+[-.\alpha](\d+)$`)
// U returns a Gerrit User for `username`@example.com as gerritpb.AccountInfo.
//
// AccountID is either 1 or taken from the ending digits of a username.
func U(username string) *gerritpb.AccountInfo {
accountID := int64(1)
if subs := usernameToAccountIDRegexp.FindSubmatch([]byte(username)); len(subs) > 0 {
i, err := strconv.ParseInt(string(subs[1]), 10, 64)
if err != nil {
panic(err)
}
accountID = i
}
email := username + "@example.com"
return &gerritpb.AccountInfo{
Email: email,
AccountId: accountID,
}
}
// MetaRevID sets .MetaRevID for the given change.
func MetaRevID(metaRevID string) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
ci.MetaRevID = metaRevID
}
}
// ParentCommits sets the parent commits for the current revision.
func ParentCommits(parents []string) CIModifier {
return func(ci *gerritpb.ChangeInfo) {
if ci.GetCurrentRevision() == "" {
panic("missing current revision")
}
revInfo, ok := ci.GetRevisions()[ci.GetCurrentRevision()]
if !ok {
panic("missing revision info for current revision")
}
revInfo.GetCommit().Parents = make([]*gerritpb.CommitInfo_Parent, len(parents))
for i, parent := range parents {
revInfo.GetCommit().Parents[i] = &gerritpb.CommitInfo_Parent{
Id: parent,
}
}
}
}
///////////////////////////////////////////////////////////////////////////////
// Getters / Mutators
// Has returns if given change exists.
func (f *Fake) Has(host string, change int) bool {
f.m.Lock()
defer f.m.Unlock()
_, ok := f.cs[key(host, change)]
return ok
}
// GetChange returns a copy of a Change that must exist. Panics otherwise.
func (f *Fake) GetChange(host string, change int) *Change {
f.m.Lock()
defer f.m.Unlock()
c, ok := f.cs[key(host, change)]
if !ok {
panic(fmt.Errorf("CL %s/%d not found", host, change))
}
return c.Copy()
}
// CreateChange adds a change that must not yet exist.
func (f *Fake) CreateChange(c *Change) {
f.m.Lock()
defer f.m.Unlock()
k := key(c.Host, int(c.Info.GetNumber()))
if f.cs == nil {
f.cs = map[string]*Change{k: c}
return
}
if _, ok := f.cs[k]; ok {
panic(fmt.Errorf("CL %s already exists", k))
}
f.cs[k] = c.Copy()
}
// MutateChange modifies a change while holding a lock blocking concurrent RPCs.
// Change must exist. Panics otherwise.
func (f *Fake) MutateChange(host string, change int, mut func(c *Change)) {
k := key(host, change)
f.m.Lock()
defer f.m.Unlock()
c, ok := f.cs[k]
if !ok {
panic(fmt.Errorf("CL %s/%d not found", host, change))
}
mut(c)
// Make a copy, to avoid accidental mutation at call sites.
f.cs[k] = c.Copy()
}
// DeleteChange deletes a change that must exist. Panics otherwise.
func (f *Fake) DeleteChange(host string, change int) {
k := key(host, change)
f.m.Lock()
defer f.m.Unlock()
if _, ok := f.cs[k]; !ok {
panic(fmt.Errorf("CL %s/%d not found", host, change))
}
delete(f.cs, k)
}
// SetDependsOn establishes Git relationship between a child CL and 1 or more
// parents, which are considered dependencies of the child CL.
//
// Child and each parent can be specified as either:
// * Change or ChangeInfo, in which case their current patchset is used,
// * <change>_<patchset>, e.g. "10_3".
func (f *Fake) SetDependsOn(host string, child interface{}, parents ...interface{}) {
f.m.Lock()
defer f.m.Unlock()
if f.parentsOf == nil {
f.parentsOf = make(map[string][]string, 1)
}
if f.childrenOf == nil {
f.childrenOf = make(map[string][]string, len(parents))
}
ch, ps := parseChangePatchset(child)
ckey := psKey(host, ch, ps)
if _, _, _, err := f.resolvePSKeyLocked(ckey); err != nil {
panic(err)
}
for _, p := range parents {
ch, ps = parseChangePatchset(p)
pkey := psKey(host, ch, ps)
if pkey == ckey {
panic(fmt.Errorf("same child %q and parent %q", ckey, pkey))
}
if _, _, _, err := f.resolvePSKeyLocked(pkey); err != nil {
panic(err)
}
f.parentsOf[ckey] = append(f.parentsOf[ckey], pkey)
f.childrenOf[pkey] = append(f.childrenOf[pkey], ckey)
}
}
///////////////////////////////////////////////////////////////////////////////
// Helpers
func (c *Change) key() string {
return key(c.Host, int(c.Info.GetNumber()))
}
func key(host string, change int) string {
return fmt.Sprintf("%s/%d", host, change)
}
func psKey(host string, change, ps int) string {
return fmt.Sprintf("%s/%d/%d", host, change, ps)
}
func splitPSKey(k string) (key string, ps int) {
i := strings.LastIndex(k, "/")
return k[:i], atoi(k[i+1:])
}
func (c *Change) resolveRevision(r string) (int, *gerritpb.RevisionInfo, error) {
if ri, ok := c.Info.GetRevisions()[r]; ok {
return int(ri.GetNumber()), ri, nil
}
if ps, err := strconv.Atoi(r); err == nil {
_, ri := c.findRevisionForPS(ps)
if ri != nil {
return ps, ri, nil
}
}
return 0, nil, status.Errorf(codes.NotFound,
"couldn't resolve change %d revision %q", c.Info.GetNumber(), r)
}
func (c *Change) findRevisionForPS(ps int) (rev string, ri *gerritpb.RevisionInfo) {
for rev, ri := range c.Info.GetRevisions() {
if ri.GetNumber() == int32(ps) {
return rev, ri
}
}
return "", nil
}
func atoi64(s string) int64 {
a, err := strconv.ParseInt(s, 10, 64)
if err != nil {
panic(fmt.Errorf("invalid int %q: %s", s, err))
}
return a
}
func atoi(s string) int {
return int(atoi64(s))
}
func parseChangePatchset(s interface{}) (int, int) {
switch v := s.(type) {
case *gerritpb.ChangeInfo:
return int(v.GetNumber()), int(v.GetRevisions()[v.GetCurrentRevision()].GetNumber())
case *Change:
return parseChangePatchset(v.Info)
case string:
if j := strings.IndexRune(v, '_'); j != -1 {
return int(atoi64(v[:j])), int(atoi64(v[j+1:]))
}
panic(fmt.Errorf("unsupported %q: use change_patchset e.g. 123_1", v))
default:
panic(fmt.Errorf("unsupported type %T %v as change patchset", s, v))
}
}
func (f *Fake) resolvePSKeyLocked(psk string) (ch *Change, rev string, ri *gerritpb.RevisionInfo, err error) {
k, ps := splitPSKey(psk)
var ok bool
ch, ok = f.cs[k]
if !ok {
err = status.Errorf(codes.Unknown, "fake relation chain invalid: missing %s change", k)
return
}
rev, ri = ch.findRevisionForPS(ps)
if ri == nil {
err = status.Errorf(codes.Unknown, "fake relation chain invalid: missing patchset %d for %s change", ps, k)
}
return
}