go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/tryjob/requirement/location.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package requirement
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"regexp"
    21  
    22  	"go.chromium.org/luci/common/data/stringset"
    23  	"go.chromium.org/luci/common/errors"
    24  	"go.chromium.org/luci/common/logging"
    25  
    26  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    27  	"go.chromium.org/luci/cv/internal/changelist"
    28  	"go.chromium.org/luci/cv/internal/run"
    29  )
    30  
    31  // locationFilterMatch returns true if a builder is included, given
    32  // the location filters and CLs (and their file paths).
    33  func locationFilterMatch(ctx context.Context, locationFilters []*cfgpb.Verifiers_Tryjob_Builder_LocationFilter, cls []*run.RunCL) (bool, error) {
    34  	if len(locationFilters) == 0 {
    35  		// If there are no location filters, the builder is included.
    36  		return true, nil
    37  	}
    38  
    39  	// For efficiency, pre-compile all regexes here. This also checks whether
    40  	// the regexes are valid.
    41  	compiled, err := compileLocationFilters(ctx, locationFilters)
    42  	if err != nil {
    43  		return false, err
    44  	}
    45  
    46  	for _, cl := range cls {
    47  		gerrit := cl.Detail.GetGerrit()
    48  		if gerrit == nil {
    49  			// Could result from error or if there is an non-Gerrit backend.
    50  			return false, errors.New("empty Gerrit detail")
    51  		}
    52  		host := gerrit.GetHost()
    53  		project := gerrit.GetInfo().GetProject()
    54  
    55  		if isMergeCommit(ctx, gerrit) {
    56  			if hostAndProjectMatch(compiled, host, project) {
    57  				// Gerrit treats CLs representing merged commits (i.e. CLs with with a
    58  				// git commit with multiple parents) as having no file diff. There may
    59  				// also be no file diff if there is no longer a diff after rebase.
    60  				// For merge commits, we want to avoid inadvertently landing
    61  				// such a CLs without triggering any builders.
    62  				//
    63  				// If there is a CL which is a merge commit, and the builder would
    64  				// be triggered for some files in that repo, then trigger the builder.
    65  				// See crbug/1006534.
    66  				return true, nil
    67  			}
    68  			continue
    69  		}
    70  		// Iterate through all files to try to find a match.
    71  		// If there are no files, but this is not a merge commit, then do
    72  		// nothing for this CL.
    73  		for _, path := range gerrit.GetFiles() {
    74  			// If the first filter is an exclude filter, then include by default, and
    75  			// vice versa.
    76  			included := locationFilters[0].Exclude
    77  			// Whether the file is included is determined by the last filter to match.
    78  			// So we can iterate through the filters backwards and break when we have
    79  			// a match.
    80  			for i := len(compiled) - 1; i >= 0; i-- {
    81  				f := compiled[i]
    82  				// Check for inclusion; if it matches then this is the filter
    83  				// that applies.
    84  				if match(f.hostRE, host) && match(f.projectRE, project) && match(f.pathRE, path) {
    85  					included = !f.exclude
    86  					break
    87  				}
    88  			}
    89  			// If at least one file in one CL is included, then the builder is included.
    90  			if included {
    91  				return true, nil
    92  			}
    93  		}
    94  	}
    95  
    96  	// After looping through all files in all CLs, all were considered
    97  	// excluded, so the builder should not be triggered.
    98  	return false, nil
    99  }
   100  
   101  // match is like re.MatchString, but also matches if the regex is nil.
   102  func match(re *regexp.Regexp, str string) bool {
   103  	return re == nil || re.MatchString(str)
   104  }
   105  
   106  // compileLocationFilters precompiles regexes in a LocationFilter.
   107  //
   108  // Returns an error if a regex is invalid.
   109  func compileLocationFilters(ctx context.Context, locationFilters []*cfgpb.Verifiers_Tryjob_Builder_LocationFilter) ([]compiledLocationFilter, error) {
   110  	ret := make([]compiledLocationFilter, len(locationFilters))
   111  	for i, lf := range locationFilters {
   112  		var err error
   113  		if lf.GerritHostRegexp != "" {
   114  			ret[i].hostRE, err = regexp.Compile(fmt.Sprintf("^%s$", lf.GerritHostRegexp))
   115  			if err != nil {
   116  				return nil, err
   117  			}
   118  		}
   119  		if lf.GerritProjectRegexp != "" {
   120  			ret[i].projectRE, err = regexp.Compile(fmt.Sprintf("^%s$", lf.GerritProjectRegexp))
   121  			if err != nil {
   122  				return nil, err
   123  			}
   124  		}
   125  		if lf.PathRegexp != "" {
   126  			ret[i].pathRE, err = regexp.Compile(fmt.Sprintf("^%s$", lf.PathRegexp))
   127  			if err != nil {
   128  				return nil, err
   129  			}
   130  		}
   131  		ret[i].exclude = lf.Exclude
   132  	}
   133  	return ret, nil
   134  }
   135  
   136  // compiledLocationFilter stores the same information as the LocationFilter
   137  // message in the config proto, but with compiled regexes.
   138  type compiledLocationFilter struct {
   139  	// Compiled regexes; nil if the regex is nil in the LocationFilter.
   140  	hostRE, projectRE, pathRE *regexp.Regexp
   141  	// Whether this filter is an exclude filter.
   142  	exclude bool
   143  }
   144  
   145  // hostAndProjectMatch returns true if the Gerrit host and project could match
   146  // the filters (for any possible files).
   147  func hostAndProjectMatch(compiled []compiledLocationFilter, host, project string) bool {
   148  	for _, f := range compiled {
   149  		if !f.exclude && match(f.hostRE, host) && match(f.projectRE, project) {
   150  			return true
   151  		}
   152  	}
   153  	// If the first filter is an exclude filter, we include by default;
   154  	// exclude by default.
   155  	return compiled[0].exclude
   156  }
   157  
   158  // isMergeCommit checks whether the current revision of the change is a merge
   159  // commit based on available Gerrit information.
   160  func isMergeCommit(ctx context.Context, g *changelist.Gerrit) bool {
   161  	i := g.GetInfo()
   162  	rev, ok := i.GetRevisions()[i.GetCurrentRevision()]
   163  	if !ok {
   164  		logging.Errorf(ctx, "No current revision in ChangeInfo when checking isMergeCommit, got %+v", i)
   165  		return false
   166  	}
   167  	return len(rev.GetCommit().GetParents()) > 1 && len(g.GetFiles()) == 0
   168  }
   169  
   170  // locationMatch returns true if the builder should be included given the
   171  // location regexp fields and CLs.
   172  //
   173  // The builder is included if at least one file from at least one CL matches
   174  // a locationRegexp pattern and does not match any locationRegexpExclude
   175  // patterns.
   176  //
   177  // Note that an empty locationRegexp is treated equivalently to a `.*` value.
   178  //
   179  // Panics if any regex is invalid.
   180  func locationMatch(ctx context.Context, locationRegexp, locationRegexpExclude []string, cls []*run.RunCL) (bool, error) {
   181  	if len(locationRegexp) == 0 {
   182  		locationRegexp = append(locationRegexp, ".*")
   183  	}
   184  	changedLocations := make(stringset.Set)
   185  	for _, cl := range cls {
   186  		if isMergeCommit(ctx, cl.Detail.GetGerrit()) {
   187  			// Merge commits have zero changed files. We don't want to land such
   188  			// changes without triggering any builders, so we ignore location filters
   189  			// if there are any CLs that are merge commits. See crbug/1006534.
   190  			return true, nil
   191  		}
   192  		host, project := cl.Detail.GetGerrit().GetHost(), cl.Detail.GetGerrit().GetInfo().GetProject()
   193  		for _, f := range cl.Detail.GetGerrit().GetFiles() {
   194  			changedLocations.Add(fmt.Sprintf("https://%s/%s/+/%s", host, project, f))
   195  		}
   196  	}
   197  	// First remove from the list the files that match locationRegexpExclude, and
   198  	for _, lre := range locationRegexpExclude {
   199  		re := regexp.MustCompile(fmt.Sprintf("^%s$", lre))
   200  		changedLocations.Iter(func(loc string) bool {
   201  			if re.MatchString(loc) {
   202  				changedLocations.Del(loc)
   203  			}
   204  			return true
   205  		})
   206  	}
   207  	if changedLocations.Len() == 0 {
   208  		// Any locations touched by the change have been excluded by the
   209  		// builder's config.
   210  		return false, nil
   211  	}
   212  	// Matching the remainder against locationRegexp, returning true if there's
   213  	// a match.
   214  	for _, lri := range locationRegexp {
   215  		re := regexp.MustCompile(fmt.Sprintf("^%s$", lri))
   216  		found := false
   217  		changedLocations.Iter(func(loc string) bool {
   218  			if re.MatchString(loc) {
   219  				found = true
   220  				return false
   221  			}
   222  			return true
   223  		})
   224  		if found {
   225  			return true, nil
   226  		}
   227  	}
   228  	return false, nil
   229  }