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  }