123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530 |
- /*
- Copyright 2017 The Kubernetes 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 config
- import (
- "errors"
- "fmt"
- "github.com/xanzy/go-gitlab"
- "strings"
- "sync"
- "time"
- "github.com/sirupsen/logrus"
- "k8s.io/apimachinery/pkg/util/sets"
- "k8s.io/test-infra/prow/gitserver"
- )
- // TideQueries is a TideQuery slice.
- type TideQueries []TideQuery
- // TideContextPolicy configures options about how to handle various contexts.
- type TideContextPolicy struct {
- // whether to consider unknown contexts optional (skip) or required.
- SkipUnknownContexts *bool `json:"skip-unknown-contexts,omitempty"`
- RequiredContexts []string `json:"required-contexts,omitempty"`
- OptionalContexts []string `json:"optional-contexts,omitempty"`
- // Infer required and optional jobs from Branch Protection configuration
- FromBranchProtection *bool `json:"from-branch-protection,omitempty"`
- }
- // TideOrgContextPolicy overrides the policy for an org, and any repo overrides.
- type TideOrgContextPolicy struct {
- TideContextPolicy
- Repos map[string]TideRepoContextPolicy `json:"repos,omitempty"`
- }
- // TideRepoContextPolicy overrides the policy for repo, and any branch overrides.
- type TideRepoContextPolicy struct {
- TideContextPolicy
- Branches map[string]TideContextPolicy `json:"branches,omitempty"`
- }
- // TideContextPolicyOptions holds the default policy, and any org overrides.
- type TideContextPolicyOptions struct {
- TideContextPolicy
- // Github Orgs
- Orgs map[string]TideOrgContextPolicy `json:"orgs,omitempty"`
- }
- // Tide is config for the tide pool.
- type Tide struct {
- // SyncPeriodString compiles into SyncPeriod at load time.
- SyncPeriodString string `json:"sync_period,omitempty"`
- // SyncPeriod specifies how often Tide will sync jobs with Github. Defaults to 1m.
- SyncPeriod time.Duration `json:"-"`
- // StatusUpdatePeriodString compiles into StatusUpdatePeriod at load time.
- StatusUpdatePeriodString string `json:"status_update_period,omitempty"`
- // StatusUpdatePeriod specifies how often Tide will update Github status contexts.
- // Defaults to the value of SyncPeriod.
- StatusUpdatePeriod time.Duration `json:"-"`
- // Queries represents a list of GitHub search queries that collectively
- // specify the set of PRs that meet merge requirements.
- Queries TideQueries `json:"queries,omitempty"`
- // A key/value pair of an org/repo as the key and merge method to override
- // the default method of merge. Valid options are squash, rebase, and merge.
- MergeType map[string]gitserver.PullRequestMergeType `json:"merge_method,omitempty"`
- // URL for tide status contexts.
- // We can consider allowing this to be set separately for separate repos, or
- // allowing it to be a template.
- TargetURL string `json:"target_url,omitempty"`
- // PRStatusBaseURL is the base URL for the PR status page.
- // This is used to link to a merge requirements overview
- // in the tide status context.
- PRStatusBaseURL string `json:"pr_status_base_url,omitempty"`
- // BlockerLabel is an optional label that is used to identify merge blocking
- // Github issues.
- // Leave this blank to disable this feature and save 1 API token per sync loop.
- BlockerLabel string `json:"blocker_label,omitempty"`
- // SquashLabel is an optional label that is used to identify PRs that should
- // always be squash merged.
- // Leave this blank to disable this feature.
- SquashLabel string `json:"squash_label,omitempty"`
- // MaxGoroutines is the maximum number of goroutines spawned inside the
- // controller to handle org/repo:branch pools. Defaults to 20. Needs to be a
- // positive number.
- MaxGoroutines int `json:"max_goroutines,omitempty"`
- // TideContextPolicyOptions defines merge options for context. If not set it will infer
- // the required and optional contexts from the prow jobs configured and use the github
- // combined status; otherwise it may apply the branch protection setting or let user
- // define their own options in case branch protection is not used.
- ContextOptions TideContextPolicyOptions `json:"context_options,omitempty"`
- }
- // MergeMethod returns the merge method to use for a repo. The default of merge is
- // returned when not overridden.
- func (t *Tide) MergeMethod(org, repo string) gitserver.PullRequestMergeType {
- name := org + "/" + repo
- v, ok := t.MergeType[name]
- if !ok {
- if ov, found := t.MergeType[org]; found {
- return ov
- }
- return gitserver.MergeMerge
- }
- return v
- }
- // TideQuery is turned into a GitHub search query. See the docs for details:
- // https://help.github.com/articles/searching-issues-and-pull-requests/
- type TideQuery struct {
- Orgs []string `json:"orgs,omitempty"`
- Repos []string `json:"repos,omitempty"`
- ExcludedRepos []string `json:"excludedRepos,omitempty"`
- ExcludedBranches []string `json:"excludedBranches,omitempty"`
- IncludedBranches []string `json:"includedBranches,omitempty"`
- Labels []string `json:"labels,omitempty"`
- MissingLabels []string `json:"missingLabels,omitempty"`
- Milestone string `json:"milestone,omitempty"`
- ReviewApprovedRequired bool `json:"reviewApprovedRequired,omitempty"`
- }
- // Query returns the corresponding github search string for the tide query.
- func (tq *TideQuery) Query() string {
- toks := []string{"is:pr", "state:open"}
- for _, o := range tq.Orgs {
- toks = append(toks, fmt.Sprintf("org:\"%s\"", o))
- }
- for _, r := range tq.Repos {
- toks = append(toks, fmt.Sprintf("repo:\"%s\"", r))
- }
- for _, r := range tq.ExcludedRepos {
- toks = append(toks, fmt.Sprintf("-repo:\"%s\"", r))
- }
- for _, b := range tq.ExcludedBranches {
- toks = append(toks, fmt.Sprintf("-base:\"%s\"", b))
- }
- for _, b := range tq.IncludedBranches {
- toks = append(toks, fmt.Sprintf("base:\"%s\"", b))
- }
- for _, l := range tq.Labels {
- toks = append(toks, fmt.Sprintf("label:\"%s\"", l))
- }
- for _, l := range tq.MissingLabels {
- toks = append(toks, fmt.Sprintf("-label:\"%s\"", l))
- }
- if tq.Milestone != "" {
- toks = append(toks, fmt.Sprintf("milestone:\"%s\"", tq.Milestone))
- }
- if tq.ReviewApprovedRequired {
- toks = append(toks, "review:approved")
- }
- return strings.Join(toks, " ")
- }
- func (tq *TideQuery) ListProjectMergeRequestsOptions(start, end *time.Time) *gitlab.ListProjectMergeRequestsOptions {
- opened := "opened"
- options := &gitlab.ListProjectMergeRequestsOptions{
- State: &opened,
- CreatedBefore: start,
- CreatedAfter: end,
- }
- if len(tq.Labels) > 0 {
- options.Labels = gitlab.Labels(tq.Labels)
- }
- if tq.Milestone != "" {
- options.Milestone = &tq.Milestone
- }
- return options
- }
- func (tq *TideQuery) ListProjectIssuesOptions(start, end *time.Time) *gitlab.ListProjectIssuesOptions {
- opened := "opened"
- options := &gitlab.ListProjectIssuesOptions{
- State: &opened,
- CreatedBefore: start,
- CreatedAfter: end,
- }
- if len(tq.Labels) > 0 {
- options.Labels = gitlab.Labels(tq.Labels)
- }
- if tq.Milestone != "" {
- options.Milestone = &tq.Milestone
- }
- return options
- }
- // Query returns the corresponding github search string for the tide query.
- func (tq *TideQuery) QueryGitlab() string {
- return ""
- }
- // ForRepo indicates if the tide query applies to the specified repo.
- func (tq TideQuery) ForRepo(org, repo string) bool {
- fullName := fmt.Sprintf("%s/%s", org, repo)
- for _, queryOrg := range tq.Orgs {
- if queryOrg != org {
- continue
- }
- // Check for repos excluded from the org.
- for _, excludedRepo := range tq.ExcludedRepos {
- if excludedRepo == fullName {
- return false
- }
- }
- return true
- }
- for _, queryRepo := range tq.Repos {
- if queryRepo == fullName {
- return true
- }
- }
- return false
- }
- func reposInOrg(org string, repos []string) []string {
- prefix := org + "/"
- var res []string
- for _, repo := range repos {
- if strings.HasPrefix(repo, prefix) {
- res = append(res, repo)
- }
- }
- return res
- }
- // OrgExceptionsAndRepos determines which orgs and repos a set of queries cover.
- // Output is returned as a mapping from 'included org'->'repos excluded in the org'
- // and a set of included repos.
- func (tqs TideQueries) OrgExceptionsAndRepos() (map[string]sets.String, sets.String) {
- orgs := make(map[string]sets.String)
- for i := range tqs {
- for _, org := range tqs[i].Orgs {
- applicableRepos := sets.NewString(reposInOrg(org, tqs[i].ExcludedRepos)...)
- if excepts, ok := orgs[org]; !ok {
- // We have not seen this org so the exceptions are just applicable
- // members of 'excludedRepos'.
- orgs[org] = applicableRepos
- } else {
- // We have seen this org so the exceptions are the applicable
- // members of 'excludedRepos' intersected with existing exceptions.
- orgs[org] = excepts.Intersection(applicableRepos)
- }
- }
- }
- repos := sets.NewString()
- for i := range tqs {
- repos.Insert(tqs[i].Repos...)
- }
- // Remove any org exceptions that are explicitly included in a different query.
- reposList := repos.UnsortedList()
- for _, excepts := range orgs {
- excepts.Delete(reposList...)
- }
- return orgs, repos
- }
- // QueryMap is a struct mapping from "org/repo" -> TideQueries that
- // apply to that org or repo. It is lazily populated, but threadsafe.
- type QueryMap struct {
- queries TideQueries
- cache map[string]TideQueries
- sync.Mutex
- }
- // QueryMap creates a QueryMap from TideQueries
- func (tqs TideQueries) QueryMap() *QueryMap {
- return &QueryMap{
- queries: tqs,
- cache: make(map[string]TideQueries),
- }
- }
- // ForRepo returns the tide queries that apply to a repo.
- func (qm *QueryMap) ForRepo(org, repo string) TideQueries {
- res := TideQueries(nil)
- fullName := fmt.Sprintf("%s/%s", org, repo)
- qm.Lock()
- defer qm.Unlock()
- if qs, ok := qm.cache[fullName]; ok {
- return append(res, qs...) // Return a copy.
- }
- // Cache miss. Need to determine relevant queries.
- for _, query := range qm.queries {
- if query.ForRepo(org, repo) {
- res = append(res, query)
- }
- }
- qm.cache[fullName] = res
- return res
- }
- // Validate returns an error if the query has any errors.
- //
- // Examples include:
- // * an org name that is empty or includes a /
- // * repos that are not org/repo
- // * a label that is in both the labels and missing_labels section
- // * a branch that is in both included and excluded branch set.
- func (tq *TideQuery) Validate() error {
- duplicates := func(field string, list []string) error {
- dups := sets.NewString()
- seen := sets.NewString()
- for _, elem := range list {
- if seen.Has(elem) {
- dups.Insert(elem)
- } else {
- seen.Insert(elem)
- }
- }
- dupCount := len(list) - seen.Len()
- if dupCount == 0 {
- return nil
- }
- return fmt.Errorf("%q contains %d duplicate entries: %s", field, dupCount, strings.Join(dups.List(), ", "))
- }
- orgs := sets.NewString()
- for o := range tq.Orgs {
- if strings.Contains(tq.Orgs[o], "/") {
- return fmt.Errorf("orgs[%d]: %q contains a '/' which is not valid", o, tq.Orgs[o])
- }
- if len(tq.Orgs[o]) == 0 {
- return fmt.Errorf("orgs[%d]: is an empty string", o)
- }
- orgs.Insert(tq.Orgs[o])
- }
- if err := duplicates("orgs", tq.Orgs); err != nil {
- return err
- }
- for r := range tq.Repos {
- parts := strings.Split(tq.Repos[r], "/")
- if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
- return fmt.Errorf("repos[%d]: %q is not of the form \"org/repo\"", r, tq.Repos[r])
- }
- if orgs.Has(parts[0]) {
- return fmt.Errorf("repos[%d]: %q is already included via org: %q", r, tq.Repos[r], parts[0])
- }
- }
- if err := duplicates("repos", tq.Repos); err != nil {
- return err
- }
- if len(tq.Orgs) == 0 && len(tq.Repos) == 0 {
- return errors.New("'orgs' and 'repos' cannot both be empty")
- }
- for er := range tq.ExcludedRepos {
- parts := strings.Split(tq.ExcludedRepos[er], "/")
- if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
- return fmt.Errorf("excludedRepos[%d]: %q is not of the form \"org/repo\"", er, tq.ExcludedRepos[er])
- }
- if !orgs.Has(parts[0]) {
- return fmt.Errorf("excludedRepos[%d]: %q has no effect because org %q is not included", er, tq.ExcludedRepos[er], parts[0])
- }
- // Note: At this point we also know that this excludedRepo is not found in 'repos'.
- }
- if err := duplicates("excludedRepos", tq.ExcludedRepos); err != nil {
- return err
- }
- if invalids := sets.NewString(tq.Labels...).Intersection(sets.NewString(tq.MissingLabels...)); len(invalids) > 0 {
- return fmt.Errorf("the labels: %q are both required and forbidden", invalids.List())
- }
- if err := duplicates("labels", tq.Labels); err != nil {
- return err
- }
- if err := duplicates("missingLabels", tq.MissingLabels); err != nil {
- return err
- }
- if len(tq.ExcludedBranches) > 0 && len(tq.IncludedBranches) > 0 {
- return errors.New("both 'includedBranches' and 'excludedBranches' are specified ('excludedBranches' have no effect)")
- }
- if err := duplicates("includedBranches", tq.IncludedBranches); err != nil {
- return err
- }
- if err := duplicates("excludedBranches", tq.ExcludedBranches); err != nil {
- return err
- }
- return nil
- }
- // Validate returns an error if any contexts are both required and optional.
- func (cp *TideContextPolicy) Validate() error {
- inter := sets.NewString(cp.RequiredContexts...).Intersection(sets.NewString(cp.OptionalContexts...))
- if inter.Len() > 0 {
- return fmt.Errorf("contexts %s are defined has required and optional", strings.Join(inter.List(), ", "))
- }
- return nil
- }
- func mergeTideContextPolicy(a, b TideContextPolicy) TideContextPolicy {
- mergeBool := func(a, b *bool) *bool {
- if b == nil {
- return a
- }
- return b
- }
- c := TideContextPolicy{}
- c.FromBranchProtection = mergeBool(a.FromBranchProtection, b.FromBranchProtection)
- c.SkipUnknownContexts = mergeBool(a.SkipUnknownContexts, b.SkipUnknownContexts)
- required := sets.NewString(a.RequiredContexts...)
- optional := sets.NewString(a.OptionalContexts...)
- required.Insert(b.RequiredContexts...)
- optional.Insert(b.OptionalContexts...)
- if required.Len() > 0 {
- c.RequiredContexts = required.List()
- }
- if optional.Len() > 0 {
- c.OptionalContexts = optional.List()
- }
- return c
- }
- func parseTideContextPolicyOptions(org, repo, branch string, options TideContextPolicyOptions) TideContextPolicy {
- option := options.TideContextPolicy
- if o, ok := options.Orgs[org]; ok {
- option = mergeTideContextPolicy(option, o.TideContextPolicy)
- if r, ok := o.Repos[repo]; ok {
- option = mergeTideContextPolicy(option, r.TideContextPolicy)
- if b, ok := r.Branches[branch]; ok {
- option = mergeTideContextPolicy(option, b)
- }
- }
- }
- return option
- }
- // GetTideContextPolicy parses the prow config to find context merge options.
- // If none are set, it will use the prow jobs configured and use the default github combined status.
- // Otherwise if set it will use the branch protection setting, or the listed jobs.
- func (c Config) GetTideContextPolicy(org, repo, branch string) (*TideContextPolicy, error) {
- options := parseTideContextPolicyOptions(org, repo, branch, c.Tide.ContextOptions)
- // Adding required and optional contexts from options
- required := sets.NewString(options.RequiredContexts...)
- optional := sets.NewString(options.OptionalContexts...)
- // automatically generate required and optional entries for Prow Jobs
- prowRequired, prowOptional := BranchRequirements(org, repo, branch, c.Presubmits)
- required.Insert(prowRequired...)
- optional.Insert(prowOptional...)
- // Using Branch protection configuration
- if options.FromBranchProtection != nil && *options.FromBranchProtection {
- bp, err := c.GetBranchProtection(org, repo, branch)
- if err != nil {
- logrus.WithError(err).Warningf("Error getting branch protection for %s/%s+%s", org, repo, branch)
- } else if bp == nil {
- logrus.Warningf("branch protection not set for %s/%s+%s", org, repo, branch)
- } else if bp.Protect != nil && *bp.Protect {
- required.Insert(bp.RequiredStatusChecks.Contexts...)
- }
- }
- t := &TideContextPolicy{
- RequiredContexts: required.List(),
- OptionalContexts: optional.List(),
- SkipUnknownContexts: options.SkipUnknownContexts,
- }
- if err := t.Validate(); err != nil {
- return t, err
- }
- return t, nil
- }
- // IsOptional checks whether a context can be ignored.
- // Will return true if
- // - context is registered as optional
- // - required contexts are registered and the context provided is not required
- // Will return false otherwise. Every context is required.
- func (cp *TideContextPolicy) IsOptional(c string) bool {
- if sets.NewString(cp.OptionalContexts...).Has(c) {
- return true
- }
- if sets.NewString(cp.RequiredContexts...).Has(c) {
- return false
- }
- if cp.SkipUnknownContexts != nil && *cp.SkipUnknownContexts {
- return true
- }
- return false
- }
- // MissingRequiredContexts discard the optional contexts and only look of extra required contexts that are not provided.
- func (cp *TideContextPolicy) MissingRequiredContexts(contexts []string) []string {
- if len(cp.RequiredContexts) == 0 {
- return nil
- }
- existingContexts := sets.NewString()
- for _, c := range contexts {
- existingContexts.Insert(c)
- }
- var missingContexts []string
- for c := range sets.NewString(cp.RequiredContexts...).Difference(existingContexts) {
- missingContexts = append(missingContexts, c)
- }
- return missingContexts
- }
|