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 }