diff --git a/prow/plugins/approve/approve.go b/prow/plugins/approve/approve.go index 070bd69c5468..dfd18d012e7e 100644 --- a/prow/plugins/approve/approve.go +++ b/prow/plugins/approve/approve.go @@ -109,21 +109,12 @@ func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) approveConfig := map[string]string{} for _, repo := range enabledRepos { - opts := config.ApproveFor(repo.Org, repo.Repo) - approveConfig[repo.String()] = fmt.Sprintf("Pull requests %s require an associated issue.
Pull request authors %s implicitly approve their own PRs.
The /lgtm [cancel] command(s) %s act as approval.
A GitHub approved or changes requested review %s act as approval or cancel respectively.", doNot(opts.IssueRequired), doNot(opts.HasSelfApproval()), willNot(opts.LgtmActsAsApprove), willNot(opts.ConsiderReviewState())) + opts := config.Approve.RepoOptions(repo.Org, repo.Repo) + approveConfig[repo.String()] = fmt.Sprintf("Pull requests %s require an associated issue.
Pull request authors %s implicitly approve their own PRs.
The /lgtm [cancel] command(s) %s act as approval.
A GitHub approved or changes requested review %s act as approval or cancel respectively.", doNot(opts.AreIssueRequired()), doNot(opts.HasSelfApproval()), willNot(opts.ShouldLgtmActsAsApprove()), willNot(opts.ConsiderReviewState())) } yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ - Approve: []plugins.Approve{ - { - Repos: []string{ - "ORGANIZATION", - "ORGANIZATION/REPOSITORY", - }, - RequireSelfApproval: new(bool), - IgnoreReviewState: new(bool), - }, - }, + Approve: plugins.ConfigTree[plugins.Approve]{}, }) if err != nil { logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName) @@ -148,17 +139,18 @@ func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) } func handleGenericCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error { + opts := pc.PluginConfig.Approve.BranchOptions(ce.Repo.Owner.Login, ce.Repo.Name, ce.Repo.DefaultBranch) return handleGenericComment( pc.Logger, pc.GitHubClient, pc.OwnersClient, pc.Config.GitHubOptions, - pc.PluginConfig, + opts, &ce, ) } -func handleGenericComment(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, config *plugins.Configuration, ce *github.GenericCommentEvent) error { +func handleGenericComment(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, opts *plugins.Approve, ce *github.GenericCommentEvent) error { funcStart := time.Now() defer func() { log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handleGenericComment") @@ -173,8 +165,7 @@ func handleGenericComment(log *logrus.Entry, ghc githubClient, oc ownersClient, return err } - opts := config.ApproveFor(ce.Repo.Owner.Login, ce.Repo.Name) - if !isApprovalCommand(botUserChecker, opts.LgtmActsAsApprove, &comment{Body: ce.Body, Author: ce.User.Login}) { + if !isApprovalCommand(botUserChecker, *opts.LgtmActsAsApprove, &comment{Body: ce.Body, Author: ce.User.Login}) { log.Debug("Comment does not constitute approval, skipping event.") return nil } @@ -213,17 +204,18 @@ func handleGenericComment(log *logrus.Entry, ghc githubClient, oc ownersClient, // handleReviewEvent should only handle reviews that have no approval command. // Reviews with approval commands will be handled by handleGenericCommentEvent. func handleReviewEvent(pc plugins.Agent, re github.ReviewEvent) error { + opts := pc.PluginConfig.Approve.BranchOptions(re.Repo.Owner.Login, re.Repo.Name, re.Repo.DefaultBranch) return handleReview( pc.Logger, pc.GitHubClient, pc.OwnersClient, pc.Config.GitHubOptions, - pc.PluginConfig, + opts, &re, ) } -func handleReview(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, config *plugins.Configuration, re *github.ReviewEvent) error { +func handleReview(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, opts *plugins.Approve, re *github.ReviewEvent) error { funcStart := time.Now() defer func() { log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handleReview") @@ -238,12 +230,10 @@ func handleReview(log *logrus.Entry, ghc githubClient, oc ownersClient, githubCo return err } - opts := config.ApproveFor(re.Repo.Owner.Login, re.Repo.Name) - // Check for an approval command is in the body. If one exists, let the // genericCommentEventHandler handle this event. Approval commands override // review state. - if isApprovalCommand(botUserChecker, opts.LgtmActsAsApprove, &comment{Body: re.Review.Body, Author: re.Review.User.Login}) { + if isApprovalCommand(botUserChecker, *opts.LgtmActsAsApprove, &comment{Body: re.Review.Body, Author: re.Review.User.Login}) { log.Debug("Review constitutes approval, skipping event.") return nil } @@ -282,17 +272,18 @@ func handleReview(log *logrus.Entry, ghc githubClient, oc ownersClient, githubCo } func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error { + opts := pc.PluginConfig.Approve.BranchOptions(pre.Repo.Owner.Login, pre.Repo.Name, pre.Repo.DefaultBranch) return handlePullRequest( pc.Logger, pc.GitHubClient, pc.OwnersClient, pc.Config.GitHubOptions, - pc.PluginConfig, + opts, &pre, ) } -func handlePullRequest(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, config *plugins.Configuration, pre *github.PullRequestEvent) error { +func handlePullRequest(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, opts *plugins.Approve, pre *github.PullRequestEvent) error { funcStart := time.Now() defer func() { log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handlePullRequest") @@ -325,7 +316,7 @@ func handlePullRequest(log *logrus.Entry, ghc githubClient, oc ownersClient, git ghc, repo, githubConfig, - config.ApproveFor(pre.Repo.Owner.Login, pre.Repo.Name), + opts, &state{ org: pre.Repo.Owner.Login, repo: pre.Repo.Name, @@ -432,7 +423,7 @@ func handle(log *logrus.Entry, ghc githubClient, repo approvers.Repo, githubConf if err != nil { log.WithError(err).Errorf("Failed to find associated issue from PR body: %v", err) } - approversHandler.RequireIssue = opts.IssueRequired + approversHandler.RequireIssue = *opts.IssueRequired approversHandler.ManuallyApproved = humanAddedApproved(ghc, log, pr.org, pr.repo, pr.number, hasApprovedLabel) // Author implicitly approves their own PR if config allows it @@ -451,7 +442,7 @@ func handle(log *logrus.Entry, ghc githubClient, repo approvers.Repo, githubConf sort.SliceStable(comments, func(i, j int) bool { return comments[i].CreatedAt.Before(comments[j].CreatedAt) }) - approveComments := filterComments(comments, approvalMatcher(botUserChecker, opts.LgtmActsAsApprove, opts.ConsiderReviewState())) + approveComments := filterComments(comments, approvalMatcher(botUserChecker, *opts.LgtmActsAsApprove, opts.ConsiderReviewState())) addApprovers(&approversHandler, approveComments, pr.author, opts.ConsiderReviewState()) log.WithField("duration", time.Since(start).String()).Debug("Completed filtering approval comments in handle") diff --git a/prow/plugins/approve/approve_test.go b/prow/plugins/approve/approve_test.go index fc443f180c70..0acea4455307 100644 --- a/prow/plugins/approve/approve_test.go +++ b/prow/plugins/approve/approve_test.go @@ -60,23 +60,7 @@ func TestPluginConfig(t *testing.T) { } pa.Set(np) - orgs := map[string]bool{} - repos := map[string]bool{} - for _, config := range pa.Config().Approve { - for _, entry := range config.Repos { - if strings.Contains(entry, "/") { - if repos[entry] { - t.Errorf("The repo %q is duplicated in the 'approve' plugin configuration.", entry) - } - repos[entry] = true - } else { - if orgs[entry] { - t.Errorf("The org %q is duplicated in the 'approve' plugin configuration.", entry) - } - orgs[entry] = true - } - } - } + // no need to check for duplicates as the ConfigTree uses maps } func newTestComment(user, body string) github.IssueComment { @@ -1281,10 +1265,9 @@ Approvers can cancel approval by writing ` + "`/approve cancel`" + ` in a commen LinkURL: test.githubLinkURL, }, &plugins.Approve{ - Repos: []string{"org/repo"}, RequireSelfApproval: &rsa, - IssueRequired: test.needsIssue, - LgtmActsAsApprove: test.lgtmActsAsApprove, + IssueRequired: &test.needsIssue, + LgtmActsAsApprove: &test.lgtmActsAsApprove, IgnoreReviewState: &irs, CommandHelpLink: "https://go.k8s.io/bot-commands", PrProcessLink: "https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process", @@ -1560,17 +1543,14 @@ func TestHandleGenericComment(t *testing.T) { Host: "github.com", }, } - config := &plugins.Configuration{} - config.Approve = append(config.Approve, plugins.Approve{ - Repos: []string{test.commentEvent.Repo.Owner.Login}, - LgtmActsAsApprove: test.lgtmActsAsApprove, - }) err := handleGenericComment( logrus.WithField("plugin", "approve"), fghc, fakeOwnersClient{}, githubConfig, - config, + &plugins.Approve{ + LgtmActsAsApprove: &test.lgtmActsAsApprove, + }, &test.commentEvent, ) @@ -1779,19 +1759,16 @@ func TestHandleReview(t *testing.T) { Host: "github.com", }, } - config := &plugins.Configuration{} irs := !test.reviewActsAsApprove - config.Approve = append(config.Approve, plugins.Approve{ - Repos: []string{test.reviewEvent.Repo.Owner.Login}, - LgtmActsAsApprove: test.lgtmActsAsApprove, - IgnoreReviewState: &irs, - }) err := handleReview( logrus.WithField("plugin", "approve"), fghc, fakeOwnersClient{}, githubConfig, - config, + &plugins.Approve{ + LgtmActsAsApprove: &test.lgtmActsAsApprove, + IgnoreReviewState: &irs, + }, &test.reviewEvent, ) @@ -1935,7 +1912,7 @@ func TestHandlePullRequest(t *testing.T) { Host: "github.com", }, }, - &plugins.Configuration{}, + &plugins.Approve{}, &test.prEvent, ) @@ -1977,13 +1954,9 @@ func TestHelpProvider(t *testing.T) { { name: "All configs enabled", config: &plugins.Configuration{ - Approve: []plugins.Approve{ - { - Repos: []string{"org2/repo"}, - IssueRequired: true, - RequireSelfApproval: &[]bool{true}[0], - LgtmActsAsApprove: true, - IgnoreReviewState: &[]bool{true}[0], + Approve: plugins.ConfigTree[plugins.Approve]{ + Orgs: map[string]plugins.Org[plugins.Approve]{ + "org2": {}, }, }, }, diff --git a/prow/plugins/config.go b/prow/plugins/config.go index 4e19f9eb96cf..9a6a020d244e 100644 --- a/prow/plugins/config.go +++ b/prow/plugins/config.go @@ -63,7 +63,7 @@ type Configuration struct { Owners Owners `json:"owners,omitempty"` // Built-in plugins specific configuration. - Approve []Approve `json:"approve,omitempty"` + Approve ConfigTree[Approve] `json:"approve,omitempty"` Blockades []Blockade `json:"blockades,omitempty"` Blunderbuss Blunderbuss `json:"blunderbuss,omitempty"` Bugzilla Bugzilla `json:"bugzilla,omitempty"` @@ -299,17 +299,15 @@ type Blockade struct { // // The configuration for the approve plugin is defined as a list of these structures. type Approve struct { - // Repos is either of the form org/repos or just org. - Repos []string `json:"repos,omitempty"` // IssueRequired indicates if an associated issue is required for approval in // the specified repos. - IssueRequired bool `json:"issue_required,omitempty"` + IssueRequired *bool `json:"issue_required,omitempty"` // RequireSelfApproval requires PR authors to explicitly approve their PRs. // Otherwise the plugin assumes the author of the PR approves the changes in the PR. RequireSelfApproval *bool `json:"require_self_approval,omitempty"` // LgtmActsAsApprove indicates that the lgtm command should be used to // indicate approval - LgtmActsAsApprove bool `json:"lgtm_acts_as_approve,omitempty"` + LgtmActsAsApprove *bool `json:"lgtm_acts_as_approve,omitempty"` // IgnoreReviewState causes the approve plugin to ignore the GitHub review state. Otherwise: // * an APPROVE github review is equivalent to leaving an "/approve" message. // * A REQUEST_CHANGES github review is equivalent to leaving an /approve cancel" message. @@ -323,18 +321,40 @@ type Approve struct { PrProcessLink string `json:"pr_process_link,omitempty"` } +// DeprecatedApprove is a composed type for compatibility with the old config format +type DeprecatedApprove struct { + // Repos is either of the form org/repos or just org. + Repos []string `json:"repos,omitempty"` + + Approve +} + var ( warnDependentBugTargetRelease time.Time ) -func (a Approve) HasSelfApproval() bool { +func (a *Approve) AreIssueRequired() bool { + if a.IssueRequired != nil { + return *a.IssueRequired + } + return false +} + +func (a *Approve) ShouldLgtmActsAsApprove() bool { + if a.LgtmActsAsApprove != nil { + return *a.LgtmActsAsApprove + } + return false +} + +func (a *Approve) HasSelfApproval() bool { if a.RequireSelfApproval != nil { return !*a.RequireSelfApproval } return true } -func (a Approve) ConsiderReviewState() bool { +func (a *Approve) ConsiderReviewState() bool { if a.IgnoreReviewState != nil { return !*a.IgnoreReviewState } @@ -796,39 +816,62 @@ func (r RequireMatchingLabel) Describe() string { return str.String() } -// ApproveFor finds the Approve for a repo, if one exists. -// Approval configuration can be listed for a repository -// or an organization. -func (c *Configuration) ApproveFor(org, repo string) *Approve { - fullName := fmt.Sprintf("%s/%s", org, repo) - - a := func() *Approve { - // First search for repo config - for _, approve := range c.Approve { - if !sets.NewString(approve.Repos...).Has(fullName) { - continue +func oldToNewApprove(old DeprecatedApprove) *Approve { + a := Approve{ + IgnoreReviewState: old.IgnoreReviewState, + IssueRequired: old.IssueRequired, + LgtmActsAsApprove: old.LgtmActsAsApprove, + RequireSelfApproval: old.RequireSelfApproval, + CommandHelpLink: old.CommandHelpLink, + PrProcessLink: old.PrProcessLink, + } + return &a +} + +func oldToNewApproveConfig(old []DeprecatedApprove) ConfigTree[Approve] { + a := ConfigTree[Approve]{} + a.Orgs = make(map[string]Org[Approve]) + for _, entry := range old { + for _, repo := range entry.Repos { + s := strings.Split(repo, "/") + ao := a.Orgs[s[0]] + switch len(s) { + case 1: + ao.Config = *oldToNewApprove(entry) + case 2: + if ao.Repos == nil { + ao.Repos = make(map[string]Repo[Approve]) + } + ar := ao.Repos[s[1]] + ar.Config = *oldToNewApprove(entry) + ao.Repos[s[1]] = ar } - return &approve + a.Orgs[s[0]] = ao } + } + return a +} - // If you don't find anything, loop again looking for an org config - for _, approve := range c.Approve { - if !sets.NewString(approve.Repos...).Has(org) { - continue - } - return &approve - } +type withoutUnmarshaler[T ProwConfig] ConfigTree[T] - // Return an empty config, and use plugin defaults - return &Approve{} - }() - if a.CommandHelpLink == "" { - a.CommandHelpLink = "https://go.k8s.io/bot-commands" - } - if a.PrProcessLink == "" { - a.PrProcessLink = "https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process" +var warnTriggerDeprecatedApprove time.Time + +func (a *ConfigTree[T]) UnmarshalJSON(d []byte) error { + switch v := any(a).(type) { + case *ConfigTree[Approve]: + var oldApprove []DeprecatedApprove + if err := yaml.Unmarshal(d, &oldApprove); err == nil { + logrusutil.ThrottledWarnf(&warnTriggerDeprecatedApprove, time.Hour, "Approve plugin uses a deprecated config style, please migrate to a ConfigTree based config") + *v = oldToNewApproveConfig(oldApprove) + return nil + } + default: + return fmt.Errorf("unknown type for ConfigTree object %v", v) } - return a + var target withoutUnmarshaler[T] + err := yaml.Unmarshal(d, &target) + *a = ConfigTree[T](target) + return err } // LgtmFor finds the Lgtm for a repo, if one exists @@ -1890,7 +1933,7 @@ func (c *Configuration) mergeFrom(other *Configuration) error { errs = append(errs, fmt.Errorf("failed to merge .bugzilla from supplemental config: %w", err)) } - c.Approve = append(c.Approve, other.Approve...) + c.Approve = c.Approve.Apply(other.Approve) c.Lgtm = append(c.Lgtm, other.Lgtm...) c.Triggers = append(c.Triggers, other.Triggers...) @@ -2043,13 +2086,10 @@ func (c *Configuration) HasConfigFor() (global bool, orgs sets.String, repos set } } - for _, approveConfig := range c.Approve { - for _, orgOrRepo := range approveConfig.Repos { - if strings.Contains(orgOrRepo, "/") { - repos.Insert(orgOrRepo) - } else { - orgs.Insert(orgOrRepo) - } + for org, approveOrg := range c.Approve.Orgs { + orgs.Insert(org) + for repo := range approveOrg.Repos { + repos.Insert(repo) } } diff --git a/prow/plugins/config_test.go b/prow/plugins/config_test.go index e1fc62adaa41..7873b103c836 100644 --- a/prow/plugins/config_test.go +++ b/prow/plugins/config_test.go @@ -219,24 +219,22 @@ func TestTriggerFor(t *testing.T) { } func TestSetApproveDefaults(t *testing.T) { - c := &Configuration{ - Approve: []Approve{ - { - Repos: []string{ - "kubernetes/kubernetes", - "kubernetes-client", - }, - }, - { - Repos: []string{ - "kubernetes-sigs/cluster-api", - }, - CommandHelpLink: "https://prow.k8s.io/command-help", - PrProcessLink: "https://github.com/kubernetes/community/blob/427ccfbc7d423d8763ed756f3b8c888b7de3cf34/contributors/guide/pull-requests.md", - }, - }, - } - + var c Configuration + configYaml := ` +--- +approve: + config: + commandHelpLink: https://go.k8s.io/bot-commands + pr_process_link: https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process + orgs: + kubernetes-sigs: + repos: + cluster-api: + config: + commandHelpLink: https://prow.k8s.io/command-help + pr_process_link: https://github.com/kubernetes/community/blob/427ccfbc7d423d8763ed756f3b8c888b7de3cf34/contributors/guide/pull-requests.md +` + yaml.Unmarshal([]byte(configYaml), &c) tests := []struct { name string org string @@ -269,7 +267,7 @@ func TestSetApproveDefaults(t *testing.T) { for _, test := range tests { - a := c.ApproveFor(test.org, test.repo) + a := c.Approve.RepoOptions(test.org, test.repo) if a.CommandHelpLink != test.expectedCommandHelpLink { t.Errorf("unexpected commandHelpLink: %s, expected: %s", a.CommandHelpLink, test.expectedCommandHelpLink) @@ -2003,7 +2001,7 @@ func TestHasConfigFor(t *testing.T) { resultGenerator: func(fuzzedConfig *Configuration) (toCheck *Configuration, expectGlobal bool, expectOrgs sets.String, expectRepos sets.String) { fuzzedConfig.Plugins = nil fuzzedConfig.Bugzilla = Bugzilla{} - fuzzedConfig.Approve = nil + fuzzedConfig.Approve = ConfigTree[Approve]{} fuzzedConfig.Label.RestrictedLabels = nil fuzzedConfig.Lgtm = nil fuzzedConfig.Triggers = nil @@ -2050,13 +2048,10 @@ func TestHasConfigFor(t *testing.T) { fuzzedConfig = &Configuration{Approve: fuzzedConfig.Approve} expectOrgs, expectRepos = sets.String{}, sets.String{} - for _, approveConfig := range fuzzedConfig.Approve { - for _, orgOrRepo := range approveConfig.Repos { - if strings.Contains(orgOrRepo, "/") { - expectRepos.Insert(orgOrRepo) - } else { - expectOrgs.Insert(orgOrRepo) - } + for org, approveOrg := range fuzzedConfig.Approve.Orgs { + expectOrgs.Insert(org) + for repo := range approveOrg.Repos { + expectRepos.Insert(repo) } } @@ -2182,12 +2177,12 @@ func TestMergeFrom(t *testing.T) { }{ { name: "Approve config gets merged", - in: Configuration{Approve: []Approve{{Repos: []string{"foo/bar"}}}}, - supplementalConfigs: []Configuration{{Approve: []Approve{{Repos: []string{"foo/baz"}}}}}, - expected: Configuration{Approve: []Approve{ + in: Configuration{Approve: oldToNewApproveConfig([]DeprecatedApprove{{Repos: []string{"foo/bar"}}})}, + supplementalConfigs: []Configuration{{Approve: oldToNewApproveConfig([]DeprecatedApprove{{Repos: []string{"foo/baz"}}})}}, + expected: Configuration{Approve: oldToNewApproveConfig([]DeprecatedApprove{ {Repos: []string{"foo/bar"}}, {Repos: []string{"foo/baz"}}, - }}, + })}, }, { name: "LGTM config gets merged", diff --git a/prow/plugins/configtree.go b/prow/plugins/configtree.go new file mode 100644 index 000000000000..19e882fc29c3 --- /dev/null +++ b/prow/plugins/configtree.go @@ -0,0 +1,87 @@ +/* +Copyright 2020 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 plugins + +type ProwConfig interface { + Apply(config ProwConfig) ProwConfig +} + +// ConfigTree specifies the global generic config for a plugin. +type ConfigTree[T ProwConfig] struct { + Config T + Orgs map[string]Org[T] `json:"orgs,omitempty"` +} + +// Org holds the default config for an entire org, as well as any repo overrides. +type Org[T ProwConfig] struct { + Config T + Repos map[string]Repo[T] `json:"repos,omitempty"` +} + +// Repo holds the default config for all branches in a repo, as well as specific branch overrides. +type Repo[T ProwConfig] struct { + Config T + Branches map[string]T `json:"branches,omitempty"` +} + +// GetOrg returns the org config after merging in any global config. +func (t ConfigTree[T]) GetOrg(name string) *Org[T] { + o, ok := t.Orgs[name] + if ok { + o.Config = t.Config.Apply(o.Config).(T) + } else { + o.Config = t.Config + } + return &o +} + +// GetRepo returns the repo config after merging in any org config. +func (o Org[T]) GetRepo(name string) *Repo[T] { + r, ok := o.Repos[name] + if ok { + r.Config = o.Config.Apply(r.Config).(T) + } else { + r.Config = o.Config + } + return &r +} + +// GetBranch returns the branch config after merging in any repo config. +func (r Repo[T]) GetBranch(name string) *T { + b, ok := r.Branches[name] + if ok { + b = r.Config.Apply(b).(T) + } else { + b = r.Config + } + return &b +} + +// BranchOptions returns the plugin configuration for a given org/repo/branch. +func (t *ConfigTree[T]) BranchOptions(org, repo, branch string) *T { + return t.GetOrg(org).GetRepo(repo).GetBranch(branch) +} + +// RepoOptions returns the plugin configuration for a given org/repo. +func (t *ConfigTree[T]) RepoOptions(org, repo string) *T { + return &t.GetOrg(org).GetRepo(repo).Config +} + +// OrgOptions returns the plugin configuration for a given org. +func (t *ConfigTree[T]) OrgOptions(org string) *T { + return &t.GetOrg(org).Config +} diff --git a/prow/plugins/configtree_test.go b/prow/plugins/configtree_test.go new file mode 100644 index 000000000000..e7b3f1619609 --- /dev/null +++ b/prow/plugins/configtree_test.go @@ -0,0 +1,251 @@ +/* +Copyright 2020 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 plugins + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "sigs.k8s.io/yaml" +) + +var ( + y = true + n = false + yes = &y + no = &n +) + +func TestApproveConfigTreeApply(t *testing.T) { + var cases = []struct { + name string + child Approve + expected Approve + parent Approve + }{ + { + name: "all empty", + child: Approve{}, + expected: Approve{}, + parent: Approve{}, + }, + { + name: "empty child", + child: Approve{}, + expected: Approve{ + RequireSelfApproval: yes, + IgnoreReviewState: yes, + }, + parent: Approve{ + IssueRequired: yes, + RequireSelfApproval: yes, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + }, + { + name: "empty parent", + child: Approve{ + IssueRequired: yes, + RequireSelfApproval: yes, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + expected: Approve{ + IssueRequired: yes, + RequireSelfApproval: yes, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + parent: Approve{}, + }, + { + name: "all yes", + child: Approve{ + IssueRequired: yes, + RequireSelfApproval: yes, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + expected: Approve{ + IssueRequired: yes, + RequireSelfApproval: yes, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + parent: Approve{ + IssueRequired: yes, + RequireSelfApproval: yes, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + }, + { + name: "all no", + child: Approve{ + IssueRequired: no, + RequireSelfApproval: no, + LgtmActsAsApprove: no, + IgnoreReviewState: no, + }, + expected: Approve{ + IssueRequired: no, + RequireSelfApproval: no, + LgtmActsAsApprove: no, + IgnoreReviewState: no, + }, + parent: Approve{ + IssueRequired: no, + RequireSelfApproval: no, + LgtmActsAsApprove: no, + IgnoreReviewState: no, + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if diff := cmp.Diff(c.expected, c.parent.Apply(c.child)); diff != "" { + t.Error("returned config does not match expected for kubernetes\n", diff) + } + }) + } +} + +func TestApproveConfigTree(t *testing.T) { + var cases = []struct { + name string + config []byte + expectedApproveBranch *Approve + expectedApproveOrg *Approve + expectedApproveRepo *Approve + }{ + { + name: "approve no orgs", + config: []byte(` +config: + issue_required: no + require_self_approval: yes + lgtm_acts_as_approve: no + ignore_review_state: yes +`), + expectedApproveBranch: &Approve{ + IssueRequired: no, + RequireSelfApproval: yes, + LgtmActsAsApprove: no, + IgnoreReviewState: yes, + }, + expectedApproveOrg: &Approve{ + IssueRequired: no, + RequireSelfApproval: yes, + LgtmActsAsApprove: no, + IgnoreReviewState: yes, + }, + expectedApproveRepo: &Approve{ + IssueRequired: no, + RequireSelfApproval: yes, + LgtmActsAsApprove: no, + IgnoreReviewState: yes, + }, + }, + { + name: "approve no default", + config: []byte(` +orgs: + bazelbuild: + config: + ignore_review_state: no + kubernetes: + config: + lgtm_acts_as_approve: yes + repos: + kops: + config: + lgtm_acts_as_approve: no + kubernetes: + config: + require_self_approval: yes +`), + expectedApproveBranch: &Approve{ + RequireSelfApproval: yes, + }, + expectedApproveOrg: &Approve{ + LgtmActsAsApprove: yes, + }, + expectedApproveRepo: &Approve{ + RequireSelfApproval: yes, + }, + }, + { + name: "approve full", + config: []byte(` +config: + issue_required: no + require_self_approval: no + lgtm_acts_as_approve: no + ignore_review_state: yes +orgs: + bazelbuild: + config: + ignore_review_state: no + kubernetes: + config: + lgtm_acts_as_approve: yes + repos: + kops: + config: + lgtm_acts_as_approve: no + kubernetes: + config: + require_self_approval: yes + branches: + master: + require_self_approval: no +`), + expectedApproveBranch: &Approve{ + RequireSelfApproval: no, + IgnoreReviewState: yes, + }, + expectedApproveOrg: &Approve{ + RequireSelfApproval: no, + LgtmActsAsApprove: yes, + IgnoreReviewState: yes, + }, + expectedApproveRepo: &Approve{ + RequireSelfApproval: yes, + IgnoreReviewState: yes, + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var tree ConfigTree[Approve] + if err := yaml.Unmarshal(c.config, &tree); err != nil { + t.Errorf("error unmarshaling config: %v", err) + } + if diff := cmp.Diff(c.expectedApproveOrg, tree.OrgOptions("kubernetes")); diff != "" { + t.Error("returned config does not match expected for kubernetes\n", diff) + } + if diff := cmp.Diff(c.expectedApproveRepo, tree.RepoOptions("kubernetes", "kubernetes")); diff != "" { + t.Error("returned config does not match expected for kubernetes/kubernetes\n", diff) + } + if diff := cmp.Diff(c.expectedApproveBranch, tree.BranchOptions("kubernetes", "kubernetes", "master")); diff != "" { + t.Error("returned config does not match expected for kubernetes/kubernetes:master\n", diff) + } + }) + } +} diff --git a/prow/plugins/helpers.go b/prow/plugins/helpers.go new file mode 100644 index 000000000000..e0a5c11ea482 --- /dev/null +++ b/prow/plugins/helpers.go @@ -0,0 +1,65 @@ +/* +Copyright 2020 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 plugins + +// Apply returns a policy that merges the child into the parent +func (parent Approve) Apply(child ProwConfig) ProwConfig { + new := Approve{ + IssueRequired: child.(Approve).IssueRequired, + RequireSelfApproval: selectBool(parent.RequireSelfApproval, child.(Approve).RequireSelfApproval), + LgtmActsAsApprove: child.(Approve).LgtmActsAsApprove, + IgnoreReviewState: selectBool(parent.IgnoreReviewState, child.(Approve).IgnoreReviewState), + CommandHelpLink: child.(Approve).CommandHelpLink, + PrProcessLink: child.(Approve).PrProcessLink, + } + return new +} + +// Apply returns a policy tree that merges the child into the parent +func (parent ConfigTree[T]) Apply(child ConfigTree[T]) ConfigTree[T] { + parent.Config = parent.Config.Apply(child.Config).(T) + for org, childOrg := range child.Orgs { + if parentOrg, ok := parent.Orgs[org]; ok { + parentOrg.Config = parentOrg.Config.Apply(childOrg.Config).(T) + for repo, childRepo := range childOrg.Repos { + if parentRepo, ok := parentOrg.Repos[repo]; ok { + parentRepo.Config = parentRepo.Config.Apply(childRepo.Config).(T) + for branch, childBranch := range childRepo.Branches { + if parentBranch, ok := parentRepo.Branches[branch]; ok { + parentBranch.Apply(childBranch) + } else { + parentRepo.Branches[branch] = childBranch + } + } + } else { + parentOrg.Repos[repo] = childRepo + } + } + } else { + parent.Orgs[org] = childOrg + } + } + return parent +} + +// selectBool returns the child argument if set, otherwise the parent +func selectBool(parent, child *bool) *bool { + if child != nil { + return child + } + return parent +}