github.com/jenkins-x/test-infra@v0.0.7/prow/config/tide.go (about) 1 /* 2 Copyright 2017 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package config 18 19 import ( 20 "errors" 21 "fmt" 22 "strings" 23 "sync" 24 "time" 25 26 "github.com/sirupsen/logrus" 27 28 "k8s.io/apimachinery/pkg/util/sets" 29 "k8s.io/test-infra/prow/github" 30 ) 31 32 // TideQueries is a TideQuery slice. 33 type TideQueries []TideQuery 34 35 // TideContextPolicy configures options about how to handle various contexts. 36 type TideContextPolicy struct { 37 // whether to consider unknown contexts optional (skip) or required. 38 SkipUnknownContexts *bool `json:"skip-unknown-contexts,omitempty"` 39 RequiredContexts []string `json:"required-contexts,omitempty"` 40 OptionalContexts []string `json:"optional-contexts,omitempty"` 41 // Infer required and optional jobs from Branch Protection configuration 42 FromBranchProtection *bool `json:"from-branch-protection,omitempty"` 43 } 44 45 // TideOrgContextPolicy overrides the policy for an org, and any repo overrides. 46 type TideOrgContextPolicy struct { 47 TideContextPolicy 48 Repos map[string]TideRepoContextPolicy `json:"repos,omitempty"` 49 } 50 51 // TideRepoContextPolicy overrides the policy for repo, and any branch overrides. 52 type TideRepoContextPolicy struct { 53 TideContextPolicy 54 Branches map[string]TideContextPolicy `json:"branches,omitempty"` 55 } 56 57 // TideContextPolicyOptions holds the default policy, and any org overrides. 58 type TideContextPolicyOptions struct { 59 TideContextPolicy 60 // Github Orgs 61 Orgs map[string]TideOrgContextPolicy `json:"orgs,omitempty"` 62 } 63 64 // Tide is config for the tide pool. 65 type Tide struct { 66 // SyncPeriodString compiles into SyncPeriod at load time. 67 SyncPeriodString string `json:"sync_period,omitempty"` 68 // SyncPeriod specifies how often Tide will sync jobs with Github. Defaults to 1m. 69 SyncPeriod time.Duration `json:"-"` 70 // StatusUpdatePeriodString compiles into StatusUpdatePeriod at load time. 71 StatusUpdatePeriodString string `json:"status_update_period,omitempty"` 72 // StatusUpdatePeriod specifies how often Tide will update Github status contexts. 73 // Defaults to the value of SyncPeriod. 74 StatusUpdatePeriod time.Duration `json:"-"` 75 // Queries represents a list of GitHub search queries that collectively 76 // specify the set of PRs that meet merge requirements. 77 Queries TideQueries `json:"queries,omitempty"` 78 79 // A key/value pair of an org/repo as the key and merge method to override 80 // the default method of merge. Valid options are squash, rebase, and merge. 81 MergeType map[string]github.PullRequestMergeType `json:"merge_method,omitempty"` 82 83 // URL for tide status contexts. 84 // We can consider allowing this to be set separately for separate repos, or 85 // allowing it to be a template. 86 TargetURL string `json:"target_url,omitempty"` 87 88 // PRStatusBaseURL is the base URL for the PR status page. 89 // This is used to link to a merge requirements overview 90 // in the tide status context. 91 PRStatusBaseURL string `json:"pr_status_base_url,omitempty"` 92 93 // BlockerLabel is an optional label that is used to identify merge blocking 94 // Github issues. 95 // Leave this blank to disable this feature and save 1 API token per sync loop. 96 BlockerLabel string `json:"blocker_label,omitempty"` 97 98 // SquashLabel is an optional label that is used to identify PRs that should 99 // always be squash merged. 100 // Leave this blank to disable this feature. 101 SquashLabel string `json:"squash_label,omitempty"` 102 103 // MaxGoroutines is the maximum number of goroutines spawned inside the 104 // controller to handle org/repo:branch pools. Defaults to 20. Needs to be a 105 // positive number. 106 MaxGoroutines int `json:"max_goroutines,omitempty"` 107 108 // TideContextPolicyOptions defines merge options for context. If not set it will infer 109 // the required and optional contexts from the prow jobs configured and use the github 110 // combined status; otherwise it may apply the branch protection setting or let user 111 // define their own options in case branch protection is not used. 112 ContextOptions TideContextPolicyOptions `json:"context_options,omitempty"` 113 } 114 115 // MergeMethod returns the merge method to use for a repo. The default of merge is 116 // returned when not overridden. 117 func (t *Tide) MergeMethod(org, repo string) github.PullRequestMergeType { 118 name := org + "/" + repo 119 120 v, ok := t.MergeType[name] 121 if !ok { 122 if ov, found := t.MergeType[org]; found { 123 return ov 124 } 125 126 return github.MergeMerge 127 } 128 129 return v 130 } 131 132 // TideQuery is turned into a GitHub search query. See the docs for details: 133 // https://help.github.com/articles/searching-issues-and-pull-requests/ 134 type TideQuery struct { 135 Orgs []string `json:"orgs,omitempty"` 136 Repos []string `json:"repos,omitempty"` 137 ExcludedRepos []string `json:"excludedRepos,omitempty"` 138 139 ExcludedBranches []string `json:"excludedBranches,omitempty"` 140 IncludedBranches []string `json:"includedBranches,omitempty"` 141 142 Labels []string `json:"labels,omitempty"` 143 MissingLabels []string `json:"missingLabels,omitempty"` 144 145 Milestone string `json:"milestone,omitempty"` 146 147 ReviewApprovedRequired bool `json:"reviewApprovedRequired,omitempty"` 148 } 149 150 // Query returns the corresponding github search string for the tide query. 151 func (tq *TideQuery) Query() string { 152 toks := []string{"is:pr", "state:open"} 153 for _, o := range tq.Orgs { 154 toks = append(toks, fmt.Sprintf("org:\"%s\"", o)) 155 } 156 for _, r := range tq.Repos { 157 toks = append(toks, fmt.Sprintf("repo:\"%s\"", r)) 158 } 159 for _, r := range tq.ExcludedRepos { 160 toks = append(toks, fmt.Sprintf("-repo:\"%s\"", r)) 161 } 162 for _, b := range tq.ExcludedBranches { 163 toks = append(toks, fmt.Sprintf("-base:\"%s\"", b)) 164 } 165 for _, b := range tq.IncludedBranches { 166 toks = append(toks, fmt.Sprintf("base:\"%s\"", b)) 167 } 168 for _, l := range tq.Labels { 169 toks = append(toks, fmt.Sprintf("label:\"%s\"", l)) 170 } 171 for _, l := range tq.MissingLabels { 172 toks = append(toks, fmt.Sprintf("-label:\"%s\"", l)) 173 } 174 if tq.Milestone != "" { 175 toks = append(toks, fmt.Sprintf("milestone:\"%s\"", tq.Milestone)) 176 } 177 if tq.ReviewApprovedRequired { 178 toks = append(toks, "review:approved") 179 } 180 return strings.Join(toks, " ") 181 } 182 183 // ForRepo indicates if the tide query applies to the specified repo. 184 func (tq TideQuery) ForRepo(org, repo string) bool { 185 fullName := fmt.Sprintf("%s/%s", org, repo) 186 for _, queryOrg := range tq.Orgs { 187 if queryOrg != org { 188 continue 189 } 190 // Check for repos excluded from the org. 191 for _, excludedRepo := range tq.ExcludedRepos { 192 if excludedRepo == fullName { 193 return false 194 } 195 } 196 return true 197 } 198 for _, queryRepo := range tq.Repos { 199 if queryRepo == fullName { 200 return true 201 } 202 } 203 return false 204 } 205 206 func reposInOrg(org string, repos []string) []string { 207 prefix := org + "/" 208 var res []string 209 for _, repo := range repos { 210 if strings.HasPrefix(repo, prefix) { 211 res = append(res, repo) 212 } 213 } 214 return res 215 } 216 217 // OrgExceptionsAndRepos determines which orgs and repos a set of queries cover. 218 // Output is returned as a mapping from 'included org'->'repos excluded in the org' 219 // and a set of included repos. 220 func (tqs TideQueries) OrgExceptionsAndRepos() (map[string]sets.String, sets.String) { 221 orgs := make(map[string]sets.String) 222 for i := range tqs { 223 for _, org := range tqs[i].Orgs { 224 applicableRepos := sets.NewString(reposInOrg(org, tqs[i].ExcludedRepos)...) 225 if excepts, ok := orgs[org]; !ok { 226 // We have not seen this org so the exceptions are just applicable 227 // members of 'excludedRepos'. 228 orgs[org] = applicableRepos 229 } else { 230 // We have seen this org so the exceptions are the applicable 231 // members of 'excludedRepos' intersected with existing exceptions. 232 orgs[org] = excepts.Intersection(applicableRepos) 233 } 234 } 235 } 236 repos := sets.NewString() 237 for i := range tqs { 238 repos.Insert(tqs[i].Repos...) 239 } 240 // Remove any org exceptions that are explicitly included in a different query. 241 reposList := repos.UnsortedList() 242 for _, excepts := range orgs { 243 excepts.Delete(reposList...) 244 } 245 return orgs, repos 246 } 247 248 // QueryMap is a struct mapping from "org/repo" -> TideQueries that 249 // apply to that org or repo. It is lazily populated, but threadsafe. 250 type QueryMap struct { 251 queries TideQueries 252 253 cache map[string]TideQueries 254 sync.Mutex 255 } 256 257 // QueryMap creates a QueryMap from TideQueries 258 func (tqs TideQueries) QueryMap() *QueryMap { 259 return &QueryMap{ 260 queries: tqs, 261 cache: make(map[string]TideQueries), 262 } 263 } 264 265 // ForRepo returns the tide queries that apply to a repo. 266 func (qm *QueryMap) ForRepo(org, repo string) TideQueries { 267 res := TideQueries(nil) 268 fullName := fmt.Sprintf("%s/%s", org, repo) 269 270 qm.Lock() 271 defer qm.Unlock() 272 273 if qs, ok := qm.cache[fullName]; ok { 274 return append(res, qs...) // Return a copy. 275 } 276 // Cache miss. Need to determine relevant queries. 277 278 for _, query := range qm.queries { 279 if query.ForRepo(org, repo) { 280 res = append(res, query) 281 } 282 } 283 qm.cache[fullName] = res 284 return res 285 } 286 287 // Validate returns an error if the query has any errors. 288 // 289 // Examples include: 290 // * an org name that is empty or includes a / 291 // * repos that are not org/repo 292 // * a label that is in both the labels and missing_labels section 293 // * a branch that is in both included and excluded branch set. 294 func (tq *TideQuery) Validate() error { 295 duplicates := func(field string, list []string) error { 296 dups := sets.NewString() 297 seen := sets.NewString() 298 for _, elem := range list { 299 if seen.Has(elem) { 300 dups.Insert(elem) 301 } else { 302 seen.Insert(elem) 303 } 304 } 305 dupCount := len(list) - seen.Len() 306 if dupCount == 0 { 307 return nil 308 } 309 return fmt.Errorf("%q contains %d duplicate entries: %s", field, dupCount, strings.Join(dups.List(), ", ")) 310 } 311 312 orgs := sets.NewString() 313 for o := range tq.Orgs { 314 if strings.Contains(tq.Orgs[o], "/") { 315 return fmt.Errorf("orgs[%d]: %q contains a '/' which is not valid", o, tq.Orgs[o]) 316 } 317 if len(tq.Orgs[o]) == 0 { 318 return fmt.Errorf("orgs[%d]: is an empty string", o) 319 } 320 orgs.Insert(tq.Orgs[o]) 321 } 322 if err := duplicates("orgs", tq.Orgs); err != nil { 323 return err 324 } 325 326 for r := range tq.Repos { 327 parts := strings.Split(tq.Repos[r], "/") 328 if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { 329 return fmt.Errorf("repos[%d]: %q is not of the form \"org/repo\"", r, tq.Repos[r]) 330 } 331 if orgs.Has(parts[0]) { 332 return fmt.Errorf("repos[%d]: %q is already included via org: %q", r, tq.Repos[r], parts[0]) 333 } 334 } 335 if err := duplicates("repos", tq.Repos); err != nil { 336 return err 337 } 338 339 if len(tq.Orgs) == 0 && len(tq.Repos) == 0 { 340 return errors.New("'orgs' and 'repos' cannot both be empty") 341 } 342 343 for er := range tq.ExcludedRepos { 344 parts := strings.Split(tq.ExcludedRepos[er], "/") 345 if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { 346 return fmt.Errorf("excludedRepos[%d]: %q is not of the form \"org/repo\"", er, tq.ExcludedRepos[er]) 347 } 348 if !orgs.Has(parts[0]) { 349 return fmt.Errorf("excludedRepos[%d]: %q has no effect because org %q is not included", er, tq.ExcludedRepos[er], parts[0]) 350 } 351 // Note: At this point we also know that this excludedRepo is not found in 'repos'. 352 } 353 if err := duplicates("excludedRepos", tq.ExcludedRepos); err != nil { 354 return err 355 } 356 357 if invalids := sets.NewString(tq.Labels...).Intersection(sets.NewString(tq.MissingLabels...)); len(invalids) > 0 { 358 return fmt.Errorf("the labels: %q are both required and forbidden", invalids.List()) 359 } 360 if err := duplicates("labels", tq.Labels); err != nil { 361 return err 362 } 363 if err := duplicates("missingLabels", tq.MissingLabels); err != nil { 364 return err 365 } 366 367 if len(tq.ExcludedBranches) > 0 && len(tq.IncludedBranches) > 0 { 368 return errors.New("both 'includedBranches' and 'excludedBranches' are specified ('excludedBranches' have no effect)") 369 } 370 if err := duplicates("includedBranches", tq.IncludedBranches); err != nil { 371 return err 372 } 373 if err := duplicates("excludedBranches", tq.ExcludedBranches); err != nil { 374 return err 375 } 376 377 return nil 378 } 379 380 // Validate returns an error if any contexts are both required and optional. 381 func (cp *TideContextPolicy) Validate() error { 382 inter := sets.NewString(cp.RequiredContexts...).Intersection(sets.NewString(cp.OptionalContexts...)) 383 if inter.Len() > 0 { 384 return fmt.Errorf("contexts %s are defined has required and optional", strings.Join(inter.List(), ", ")) 385 } 386 return nil 387 } 388 389 func mergeTideContextPolicy(a, b TideContextPolicy) TideContextPolicy { 390 mergeBool := func(a, b *bool) *bool { 391 if b == nil { 392 return a 393 } 394 return b 395 } 396 c := TideContextPolicy{} 397 c.FromBranchProtection = mergeBool(a.FromBranchProtection, b.FromBranchProtection) 398 c.SkipUnknownContexts = mergeBool(a.SkipUnknownContexts, b.SkipUnknownContexts) 399 required := sets.NewString(a.RequiredContexts...) 400 optional := sets.NewString(a.OptionalContexts...) 401 required.Insert(b.RequiredContexts...) 402 optional.Insert(b.OptionalContexts...) 403 if required.Len() > 0 { 404 c.RequiredContexts = required.List() 405 } 406 if optional.Len() > 0 { 407 c.OptionalContexts = optional.List() 408 } 409 return c 410 } 411 412 func parseTideContextPolicyOptions(org, repo, branch string, options TideContextPolicyOptions) TideContextPolicy { 413 option := options.TideContextPolicy 414 if o, ok := options.Orgs[org]; ok { 415 option = mergeTideContextPolicy(option, o.TideContextPolicy) 416 if r, ok := o.Repos[repo]; ok { 417 option = mergeTideContextPolicy(option, r.TideContextPolicy) 418 if b, ok := r.Branches[branch]; ok { 419 option = mergeTideContextPolicy(option, b) 420 } 421 } 422 } 423 return option 424 } 425 426 // GetTideContextPolicy parses the prow config to find context merge options. 427 // If none are set, it will use the prow jobs configured and use the default github combined status. 428 // Otherwise if set it will use the branch protection setting, or the listed jobs. 429 func (c Config) GetTideContextPolicy(org, repo, branch string) (*TideContextPolicy, error) { 430 options := parseTideContextPolicyOptions(org, repo, branch, c.Tide.ContextOptions) 431 // Adding required and optional contexts from options 432 required := sets.NewString(options.RequiredContexts...) 433 optional := sets.NewString(options.OptionalContexts...) 434 435 // automatically generate required and optional entries for Prow Jobs 436 prowRequired, prowOptional := BranchRequirements(org, repo, branch, c.Presubmits) 437 required.Insert(prowRequired...) 438 optional.Insert(prowOptional...) 439 440 // Using Branch protection configuration 441 if options.FromBranchProtection != nil && *options.FromBranchProtection { 442 bp, err := c.GetBranchProtection(org, repo, branch) 443 if err != nil { 444 logrus.WithError(err).Warningf("Error getting branch protection for %s/%s+%s", org, repo, branch) 445 } else if bp == nil { 446 logrus.Warningf("branch protection not set for %s/%s+%s", org, repo, branch) 447 } else if bp.Protect != nil && *bp.Protect && bp.RequiredStatusChecks != nil { 448 required.Insert(bp.RequiredStatusChecks.Contexts...) 449 } 450 } 451 452 t := &TideContextPolicy{ 453 RequiredContexts: required.List(), 454 OptionalContexts: optional.List(), 455 SkipUnknownContexts: options.SkipUnknownContexts, 456 } 457 if err := t.Validate(); err != nil { 458 return t, err 459 } 460 return t, nil 461 } 462 463 // IsOptional checks whether a context can be ignored. 464 // Will return true if 465 // - context is registered as optional 466 // - required contexts are registered and the context provided is not required 467 // Will return false otherwise. Every context is required. 468 func (cp *TideContextPolicy) IsOptional(c string) bool { 469 if sets.NewString(cp.OptionalContexts...).Has(c) { 470 return true 471 } 472 if sets.NewString(cp.RequiredContexts...).Has(c) { 473 return false 474 } 475 if cp.SkipUnknownContexts != nil && *cp.SkipUnknownContexts { 476 return true 477 } 478 return false 479 } 480 481 // MissingRequiredContexts discard the optional contexts and only look of extra required contexts that are not provided. 482 func (cp *TideContextPolicy) MissingRequiredContexts(contexts []string) []string { 483 if len(cp.RequiredContexts) == 0 { 484 return nil 485 } 486 existingContexts := sets.NewString() 487 for _, c := range contexts { 488 existingContexts.Insert(c) 489 } 490 var missingContexts []string 491 for c := range sets.NewString(cp.RequiredContexts...).Difference(existingContexts) { 492 missingContexts = append(missingContexts, c) 493 } 494 return missingContexts 495 }