sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/cmd/branchprotector/protect.go (about)

     1  /*
     2  Copyright 2018 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 main
    18  
    19  import (
    20  	"flag"
    21  	"fmt"
    22  	"net/url"
    23  	"os"
    24  	"regexp"
    25  	"sort"
    26  	"strings"
    27  	"sync"
    28  
    29  	"github.com/sirupsen/logrus"
    30  
    31  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    32  	"k8s.io/apimachinery/pkg/util/sets"
    33  	"sigs.k8s.io/prow/pkg/config"
    34  	"sigs.k8s.io/prow/pkg/flagutil"
    35  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    36  	"sigs.k8s.io/prow/pkg/github"
    37  	"sigs.k8s.io/prow/pkg/logrusutil"
    38  )
    39  
    40  const (
    41  	defaultTokens = 300
    42  	defaultBurst  = 100
    43  )
    44  
    45  type options struct {
    46  	config                 configflagutil.ConfigOptions
    47  	confirm                bool
    48  	verifyRestrictions     bool
    49  	enableAppsRestrictions bool
    50  
    51  	github           flagutil.GitHubOptions
    52  	githubEnablement flagutil.GitHubEnablementOptions
    53  }
    54  
    55  func (o *options) Validate() error {
    56  	if err := o.github.Validate(!o.confirm); err != nil {
    57  		return err
    58  	}
    59  
    60  	if err := o.githubEnablement.Validate(!o.confirm); err != nil {
    61  		return err
    62  	}
    63  
    64  	if err := o.config.Validate(!o.confirm); err != nil {
    65  		return err
    66  	}
    67  
    68  	return nil
    69  }
    70  
    71  func gatherOptions() options {
    72  	o := options{}
    73  	fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
    74  	fs.BoolVar(&o.confirm, "confirm", false, "Mutate github if set")
    75  	fs.BoolVar(&o.verifyRestrictions, "verify-restrictions", false, "Verify the restrictions section of the request for authorized apps/collaborators/teams")
    76  	fs.BoolVar(&o.enableAppsRestrictions, "enable-apps-restrictions", false, "Enable feature to enforce apps restrictions in branch protection rules")
    77  	o.config.AddFlags(fs)
    78  	o.github.AddCustomizedFlags(fs, flagutil.ThrottlerDefaults(defaultTokens, defaultBurst))
    79  	o.githubEnablement.AddFlags(fs)
    80  	fs.Parse(os.Args[1:])
    81  	return o
    82  }
    83  
    84  type requirements struct {
    85  	Org     string
    86  	Repo    string
    87  	Branch  string
    88  	Request *github.BranchProtectionRequest
    89  }
    90  
    91  // Errors holds a list of errors, including a method to concurrently append.
    92  type Errors struct {
    93  	lock sync.Mutex
    94  	errs []error
    95  }
    96  
    97  func (e *Errors) add(err error) {
    98  	e.lock.Lock()
    99  	logrus.Info(err)
   100  	defer e.lock.Unlock()
   101  	e.errs = append(e.errs, err)
   102  }
   103  
   104  func main() {
   105  	logrusutil.ComponentInit()
   106  
   107  	o := gatherOptions()
   108  	if err := o.Validate(); err != nil {
   109  		logrus.Fatal(err)
   110  	}
   111  
   112  	ca, err := o.config.ConfigAgent()
   113  	if err != nil {
   114  		logrus.WithError(err).Fatalf("Failed to load --config-path=%s", o.config.ConfigPath)
   115  	}
   116  	cfg := ca.Config()
   117  	cfg.BranchProtectionWarnings(logrus.NewEntry(logrus.StandardLogger()), cfg.PresubmitsStatic)
   118  
   119  	githubClient, err := o.github.GitHubClient(!o.confirm)
   120  	if err != nil {
   121  		logrus.WithError(err).Fatal("Error getting GitHub client.")
   122  	}
   123  
   124  	p := protector{
   125  		client:                 githubClient,
   126  		cfg:                    cfg,
   127  		updates:                make(chan requirements),
   128  		errors:                 Errors{},
   129  		completedRepos:         make(map[string]bool),
   130  		done:                   make(chan []error),
   131  		verifyRestrictions:     o.verifyRestrictions,
   132  		enableAppsRestrictions: o.enableAppsRestrictions,
   133  		enabled:                o.githubEnablement.EnablementChecker(),
   134  	}
   135  
   136  	go p.configureBranches()
   137  	p.protect()
   138  	close(p.updates)
   139  	errors := <-p.done
   140  	if n := len(errors); n > 0 {
   141  		for i, err := range errors {
   142  			logrus.WithError(err).Error(i)
   143  		}
   144  		logrus.Fatalf("Encountered %d errors protecting branches", n)
   145  	}
   146  }
   147  
   148  type client interface {
   149  	GetBranchProtection(org, repo, branch string) (*github.BranchProtection, error)
   150  	RemoveBranchProtection(org, repo, branch string) error
   151  	UpdateBranchProtection(org, repo, branch string, config github.BranchProtectionRequest) error
   152  	GetBranches(org, repo string, onlyProtected bool) ([]github.Branch, error)
   153  	GetRepo(owner, name string) (github.FullRepo, error)
   154  	GetRepos(org string, user bool) ([]github.Repo, error)
   155  	ListAppInstallationsForOrg(org string) ([]github.AppInstallation, error)
   156  	ListCollaborators(org, repo string) ([]github.User, error)
   157  	ListRepoTeams(org, repo string) ([]github.Team, error)
   158  }
   159  
   160  type protector struct {
   161  	client                 client
   162  	cfg                    *config.Config
   163  	updates                chan requirements
   164  	errors                 Errors
   165  	completedRepos         map[string]bool
   166  	done                   chan []error
   167  	verifyRestrictions     bool
   168  	enableAppsRestrictions bool
   169  	enabled                func(org, repo string) bool
   170  }
   171  
   172  func (p *protector) configureBranches() {
   173  	for u := range p.updates {
   174  		if u.Request == nil {
   175  			if err := p.client.RemoveBranchProtection(u.Org, u.Repo, u.Branch); err != nil {
   176  				p.errors.add(fmt.Errorf("remove %s/%s=%s protection failed: %w", u.Org, u.Repo, u.Branch, err))
   177  			}
   178  			continue
   179  		}
   180  
   181  		if err := p.client.UpdateBranchProtection(u.Org, u.Repo, u.Branch, *u.Request); err != nil {
   182  			p.errors.add(fmt.Errorf("update %s/%s=%s protection to %v failed: %w", u.Org, u.Repo, u.Branch, *u.Request, err))
   183  		}
   184  	}
   185  	p.done <- p.errors.errs
   186  }
   187  
   188  // protect protects branches specified in the presubmit and branch-protection config sections.
   189  func (p *protector) protect() {
   190  	bp := p.cfg.BranchProtection
   191  	if bp.Policy.Unmanaged != nil && *bp.Policy.Unmanaged && !bp.HasManagedOrgs() && !bp.HasManagedRepos() && !bp.HasManagedBranches() {
   192  		logrus.Warn("Branchprotection has global unmanaged: true, will not do anything")
   193  		return
   194  	}
   195  
   196  	// Scan the branch-protection configuration
   197  	for orgName := range bp.Orgs {
   198  		if !p.enabled(orgName, "") {
   199  			continue
   200  		}
   201  		org := bp.GetOrg(orgName)
   202  		if err := p.UpdateOrg(orgName, *org); err != nil {
   203  			p.errors.add(fmt.Errorf("update %s: %w", orgName, err))
   204  		}
   205  	}
   206  
   207  	// Do not automatically protect tested repositories
   208  	if bp.ProtectTested == nil || !*bp.ProtectTested {
   209  		return
   210  	}
   211  
   212  	// Some repos with presubmits might not be listed in the branch-protection
   213  	// Using PresubmitsStatic here is safe because this is only about getting to
   214  	// know which repos exist. Repos that use in-repo config will appear here,
   215  	// because we generate a verification job for them
   216  	for repo := range p.cfg.PresubmitsStatic {
   217  		if p.completedRepos[repo] {
   218  			continue
   219  		}
   220  		parts := strings.Split(repo, "/")
   221  		if len(parts) != 2 { // TODO(fejta): use a strong type here instead
   222  			p.errors.add(fmt.Errorf("bad presubmit repo: %s", repo))
   223  			continue
   224  		}
   225  		orgName := parts[0]
   226  		repoName := parts[1]
   227  		if !p.enabled(orgName, repoName) {
   228  			continue
   229  		}
   230  		repo := bp.GetOrg(orgName).GetRepo(repoName)
   231  		if err := p.UpdateRepo(orgName, repoName, *repo); err != nil {
   232  			p.errors.add(fmt.Errorf("update %s/%s: %w", orgName, repoName, err))
   233  		}
   234  	}
   235  }
   236  
   237  // UpdateOrg updates all repos in the org with the specified defaults
   238  func (p *protector) UpdateOrg(orgName string, org config.Org) error {
   239  	if org.Policy.Unmanaged != nil && *org.Policy.Unmanaged && !org.HasManagedRepos() && !org.HasManagedBranches() {
   240  		return nil
   241  	}
   242  
   243  	var repos []string
   244  	if org.Protect != nil {
   245  		// Strongly opinionated org, configure every repo in the org.
   246  		rs, err := p.client.GetRepos(orgName, false)
   247  		if err != nil {
   248  			return fmt.Errorf("list repos: %w", err)
   249  		}
   250  		for _, r := range rs {
   251  			// Skip Archived repos as they can't be modified in this way
   252  			if r.Archived {
   253  				continue
   254  			}
   255  			// Skip private security forks as they can't be modified in this way
   256  			if r.Private && github.SecurityForkNameRE.MatchString(r.Name) {
   257  				continue
   258  			}
   259  			repos = append(repos, r.Name)
   260  		}
   261  	} else {
   262  		// Unopinionated org, just set explicitly defined repos
   263  		for r := range org.Repos {
   264  			repos = append(repos, r)
   265  		}
   266  	}
   267  
   268  	var errs []error
   269  	for _, repoName := range repos {
   270  		if !p.enabled(orgName, repoName) {
   271  			continue
   272  		}
   273  		repo := org.GetRepo(repoName)
   274  		if err := p.UpdateRepo(orgName, repoName, *repo); err != nil {
   275  			errs = append(errs, fmt.Errorf("update %s: %w", repoName, err))
   276  		}
   277  	}
   278  
   279  	return utilerrors.NewAggregate(errs)
   280  }
   281  
   282  // UpdateRepo updates all branches in the repo with the specified defaults
   283  func (p *protector) UpdateRepo(orgName string, repoName string, repo config.Repo) error {
   284  	p.completedRepos[orgName+"/"+repoName] = true
   285  	if repo.Policy.Unmanaged != nil && *repo.Policy.Unmanaged && !repo.HasManagedBranches() {
   286  		return nil
   287  	}
   288  
   289  	githubRepo, err := p.client.GetRepo(orgName, repoName)
   290  	if err != nil {
   291  		return fmt.Errorf("could not get repo to check for archival: %w", err)
   292  	}
   293  	// Skip Archived repos as they can't be modified in this way
   294  	if githubRepo.Archived {
   295  		return nil
   296  	}
   297  	// Skip private security forks as they can't be modified in this way
   298  	if githubRepo.Private && github.SecurityForkNameRE.MatchString(githubRepo.Name) {
   299  		return nil
   300  	}
   301  
   302  	var branchInclusions *regexp.Regexp
   303  	if len(repo.Policy.Include) > 0 {
   304  		branchInclusions, err = regexp.Compile(strings.Join(repo.Policy.Include, `|`))
   305  		if err != nil {
   306  			return err
   307  		}
   308  	}
   309  
   310  	var branchExclusions *regexp.Regexp
   311  	if len(repo.Policy.Exclude) > 0 {
   312  		branchExclusions, err = regexp.Compile(strings.Join(repo.Policy.Exclude, `|`))
   313  		if err != nil {
   314  			return err
   315  		}
   316  	}
   317  
   318  	branches := map[string]github.Branch{}
   319  	for _, onlyProtected := range []bool{false, true} { // put true second so b.Protected is set correctly
   320  		bs, err := p.client.GetBranches(orgName, repoName, onlyProtected)
   321  		if err != nil {
   322  			return fmt.Errorf("list branches: %w", err)
   323  		}
   324  		for _, b := range bs {
   325  			_, ok := repo.Branches[b.Name]
   326  			if !ok && branchInclusions != nil && branchInclusions.MatchString(b.Name) {
   327  				branches[b.Name] = b
   328  			} else if !ok && branchInclusions != nil && !branchInclusions.MatchString(b.Name) {
   329  				logrus.Infof("%s/%s=%s: not included", orgName, repoName, b.Name)
   330  				continue
   331  			} else if !ok && branchExclusions != nil && branchExclusions.MatchString(b.Name) {
   332  				logrus.Infof("%s/%s=%s: excluded", orgName, repoName, b.Name)
   333  				continue
   334  			}
   335  			branches[b.Name] = b
   336  		}
   337  	}
   338  
   339  	var apps, collaborators, teams []string
   340  	if p.verifyRestrictions {
   341  		apps, err = p.authorizedApps(orgName)
   342  		if err != nil {
   343  			logrus.Infof("%s: error getting list of installed apps: %v", orgName, err)
   344  			return err
   345  		}
   346  
   347  		collaborators, err = p.authorizedCollaborators(orgName, repoName)
   348  		if err != nil {
   349  			logrus.Infof("%s/%s: error getting list of collaborators: %v", orgName, repoName, err)
   350  			return err
   351  		}
   352  
   353  		teams, err = p.authorizedTeams(orgName, repoName)
   354  		if err != nil {
   355  			logrus.Infof("%s/%s: error getting list of teams: %v", orgName, repoName, err)
   356  			return err
   357  		}
   358  	}
   359  
   360  	var errs []error
   361  	for bn, githubBranch := range branches {
   362  		if branch, err := repo.GetBranch(bn); err != nil {
   363  			errs = append(errs, fmt.Errorf("get %s: %w", bn, err))
   364  		} else if err = p.UpdateBranch(orgName, repoName, bn, *branch, githubBranch.Protected, apps, collaborators, teams); err != nil {
   365  			errs = append(errs, fmt.Errorf("update %s from protected=%t: %w", bn, githubBranch.Protected, err))
   366  		}
   367  	}
   368  
   369  	return utilerrors.NewAggregate(errs)
   370  }
   371  
   372  // authorizedApps returns the list of slugs for apps that are authorized
   373  // to write to repositories of the org.
   374  func (p *protector) authorizedApps(org string) ([]string, error) {
   375  	appInstallations, err := p.client.ListAppInstallationsForOrg(org)
   376  	if err != nil {
   377  		return nil, err
   378  	}
   379  	var authorized []string
   380  	for _, a := range appInstallations {
   381  		if a.Permissions.Contents == string(github.Write) {
   382  			authorized = append(authorized, a.AppSlug)
   383  		}
   384  	}
   385  	return authorized, nil
   386  }
   387  
   388  // authorizedCollaborators returns the list of Logins for users that are
   389  // authorized to write to a repository.
   390  func (p *protector) authorizedCollaborators(org, repo string) ([]string, error) {
   391  	collaborators, err := p.client.ListCollaborators(org, repo)
   392  	if err != nil {
   393  		return nil, err
   394  	}
   395  	var authorized []string
   396  	for _, c := range collaborators {
   397  		if c.Permissions.Admin || c.Permissions.Push {
   398  			authorized = append(authorized, github.NormLogin(c.Login))
   399  		}
   400  	}
   401  	return authorized, nil
   402  }
   403  
   404  // authorizedTeams returns the list of slugs for teams that are authorized to
   405  // write to a repository.
   406  func (p *protector) authorizedTeams(org, repo string) ([]string, error) {
   407  	teams, err := p.client.ListRepoTeams(org, repo)
   408  	if err != nil {
   409  		return nil, err
   410  	}
   411  	var authorized []string
   412  	for _, t := range teams {
   413  		if t.Permission == github.RepoPush || t.Permission == github.RepoAdmin {
   414  			authorized = append(authorized, t.Slug)
   415  		}
   416  	}
   417  	return authorized, nil
   418  }
   419  
   420  func validateRestrictions(org, repo string, bp *github.BranchProtectionRequest, authorizedApps, authorizedCollaborators, authorizedTeams []string) []error {
   421  	if bp == nil || bp.Restrictions == nil {
   422  		return nil
   423  	}
   424  
   425  	var errs []error
   426  	if bp.Restrictions.Apps != nil {
   427  		if unauthorized := sets.New[string](*bp.Restrictions.Apps...).Difference(sets.New[string](authorizedApps...)); unauthorized.Len() > 0 {
   428  			errs = append(errs, fmt.Errorf("the following apps are not authorized for %s/%s: %s", org, repo, sets.List(unauthorized)))
   429  		}
   430  	}
   431  	if bp.Restrictions.Users != nil {
   432  		if unauthorized := sets.New[string](*bp.Restrictions.Users...).Difference(sets.New[string](authorizedCollaborators...)); unauthorized.Len() > 0 {
   433  			errs = append(errs, fmt.Errorf("the following collaborators are not authorized for %s/%s: %s", org, repo, sets.List(unauthorized)))
   434  		}
   435  	}
   436  	if bp.Restrictions.Teams != nil {
   437  		if unauthorized := sets.New[string](*bp.Restrictions.Teams...).Difference(sets.New[string](authorizedTeams...)); unauthorized.Len() > 0 {
   438  			errs = append(errs, fmt.Errorf("the following teams are not authorized for %s/%s: %s", org, repo, sets.List(unauthorized)))
   439  		}
   440  	}
   441  	return errs
   442  }
   443  
   444  // UpdateBranch updates the branch with the specified configuration
   445  func (p *protector) UpdateBranch(orgName, repo string, branchName string, branch config.Branch, protected bool, authorizedApps, authorizedCollaborators, authorizedTeams []string) error {
   446  	if branch.Unmanaged != nil && *branch.Unmanaged {
   447  		return nil
   448  	}
   449  	bp, err := p.cfg.GetPolicy(orgName, repo, branchName, branch, p.cfg.GetPresubmitsStatic(orgName+"/"+repo), &protected)
   450  	if err != nil {
   451  		return fmt.Errorf("get policy: %w", err)
   452  	}
   453  	if bp == nil || bp.Protect == nil {
   454  		return nil
   455  	}
   456  	if !protected && !*bp.Protect {
   457  		logrus.Infof("%s/%s=%s: already unprotected", orgName, repo, branchName)
   458  		return nil
   459  	}
   460  
   461  	// Return error if apps restrictions if feature is disabled, but there are apps restrictions in the config
   462  	if !p.enableAppsRestrictions && bp.Restrictions != nil && bp.Restrictions.Apps != nil {
   463  		return fmt.Errorf("'enable-apps-restrictions' command line flag is not true, but Apps Restrictions are maintained for %s/%s=%s", orgName, repo, branchName)
   464  	}
   465  
   466  	var req *github.BranchProtectionRequest
   467  	if *bp.Protect {
   468  		r := makeRequest(*bp, p.enableAppsRestrictions)
   469  		req = &r
   470  	}
   471  
   472  	if p.verifyRestrictions {
   473  		if validationErrors := validateRestrictions(orgName, repo, req, authorizedApps, authorizedCollaborators, authorizedTeams); len(validationErrors) != 0 {
   474  			logrus.Warnf("invalid branch protection request: %s/%s=%s: %v", orgName, repo, branchName, validationErrors)
   475  			errs := make([]string, 0, len(validationErrors))
   476  			for _, e := range validationErrors {
   477  				errs = append(errs, e.Error())
   478  			}
   479  			return fmt.Errorf("invalid branch protection request: %s/%s=%s: %s", orgName, repo, branchName, strings.Join(errs, "\n"))
   480  		}
   481  	}
   482  
   483  	// github API is very sensitive if branchName contains extra characters,
   484  	// therefor we need to url encode the branch name.
   485  	branchNameForRequest := url.QueryEscape(branchName)
   486  
   487  	// The github API currently does not support listing protections for all
   488  	// branches of a repository. We therefore have to make individual requests
   489  	// for each branch.
   490  	currentBP, err := p.client.GetBranchProtection(orgName, repo, branchNameForRequest)
   491  	if err != nil {
   492  		return fmt.Errorf("get current branch protection: %w", err)
   493  	}
   494  
   495  	if equalBranchProtections(currentBP, req) {
   496  		logrus.Debugf("%s/%s=%s: current branch protection matches policy, skipping", orgName, repo, branchName)
   497  		return nil
   498  	}
   499  
   500  	p.updates <- requirements{
   501  		Org:     orgName,
   502  		Repo:    repo,
   503  		Branch:  branchName,
   504  		Request: req,
   505  	}
   506  	return nil
   507  }
   508  
   509  func equalBranchProtections(state *github.BranchProtection, request *github.BranchProtectionRequest) bool {
   510  	switch {
   511  	case state == nil && request == nil:
   512  		return true
   513  	case state != nil && request != nil:
   514  		return equalRequiredStatusChecks(state.RequiredStatusChecks, request.RequiredStatusChecks) &&
   515  			equalAdminEnforcement(state.EnforceAdmins, request.EnforceAdmins) &&
   516  			equalRequiredPullRequestReviews(state.RequiredPullRequestReviews, request.RequiredPullRequestReviews) &&
   517  			equalRestrictions(state.Restrictions, request.Restrictions) &&
   518  			equalAllowForcePushes(state.AllowForcePushes, request.AllowForcePushes) &&
   519  			equalRequiredLinearHistory(state.RequiredLinearHistory, request.RequiredLinearHistory) &&
   520  			equalAllowDeletions(state.AllowDeletions, request.AllowDeletions)
   521  	default:
   522  		return false
   523  	}
   524  }
   525  
   526  func equalRequiredStatusChecks(state, request *github.RequiredStatusChecks) bool {
   527  	switch {
   528  	case state == request:
   529  		return true
   530  	case state != nil && request != nil:
   531  		return state.Strict == request.Strict &&
   532  			equalStringSlices(&state.Contexts, &request.Contexts)
   533  	default:
   534  		return false
   535  	}
   536  }
   537  
   538  func equalStringSlices(s1, s2 *[]string) bool {
   539  	switch {
   540  	case s1 == s2:
   541  		return true
   542  	case s1 != nil && s2 != nil:
   543  		if len(*s1) != len(*s2) {
   544  			return false
   545  		}
   546  		sort.Strings(*s1)
   547  		sort.Strings(*s2)
   548  		for i, v := range *s1 {
   549  			if v != (*s2)[i] {
   550  				return false
   551  			}
   552  		}
   553  		return true
   554  	default:
   555  		return false
   556  	}
   557  }
   558  
   559  func equalRequiredLinearHistory(state github.RequiredLinearHistory, request bool) bool {
   560  	return state.Enabled == request
   561  }
   562  
   563  func equalAllowDeletions(state github.AllowDeletions, request bool) bool {
   564  	return state.Enabled == request
   565  }
   566  
   567  func equalAllowForcePushes(state github.AllowForcePushes, request bool) bool {
   568  	return state.Enabled == request
   569  }
   570  
   571  func equalAdminEnforcement(state github.EnforceAdmins, request *bool) bool {
   572  	switch {
   573  	case request == nil:
   574  		// the state we read from the GitHub API will always contain
   575  		// a non-nil configuration for admins, while our request may
   576  		// be nil to signify we do not want to make any statement.
   577  		// However, not making any statement about admins will buy
   578  		// into the default behavior, which is for admins to not be
   579  		// bound by the branch protection rules. Therefore, making no
   580  		// request is equivalent to making a request to not enforce
   581  		// rules on admins.
   582  		return !state.Enabled
   583  	default:
   584  		return state.Enabled == *request
   585  	}
   586  }
   587  
   588  func equalRequiredPullRequestReviews(state *github.RequiredPullRequestReviews, request *github.RequiredPullRequestReviewsRequest) bool {
   589  	switch {
   590  	case state == nil && request == nil:
   591  		return true
   592  	case state != nil && request != nil:
   593  		return state.DismissStaleReviews == request.DismissStaleReviews &&
   594  			state.RequireCodeOwnerReviews == request.RequireCodeOwnerReviews &&
   595  			state.RequiredApprovingReviewCount == request.RequiredApprovingReviewCount &&
   596  			equalDismissalRestrictions(state.DismissalRestrictions, &request.DismissalRestrictions) &&
   597  			equalBypassRestrictions(state.BypassRestrictions, &request.BypassRestrictions)
   598  	default:
   599  		return false
   600  	}
   601  }
   602  
   603  func equalDismissalRestrictions(state *github.DismissalRestrictions, request *github.DismissalRestrictionsRequest) bool {
   604  	switch {
   605  	case state == nil && request == nil:
   606  		return true
   607  	case state == nil && request != nil:
   608  		// when there are no restrictions on users or teams, GitHub will
   609  		// omit the fields from the response we get when asking for the
   610  		// current state. If we _are_ making a request but it has no real
   611  		// effect, this is identical to making no request for restriction.
   612  		return request.Users == nil && request.Teams == nil
   613  	case state != nil && request != nil:
   614  		return equalTeams(state.Teams, request.Teams) && equalUsers(state.Users, request.Users)
   615  	default:
   616  		return false
   617  	}
   618  }
   619  
   620  func equalBypassRestrictions(state *github.BypassRestrictions, request *github.BypassRestrictionsRequest) bool {
   621  	switch {
   622  	case state == nil && request == nil:
   623  		return true
   624  	case state == nil && request != nil:
   625  		// when there are no restrictions on users or teams, GitHub will
   626  		// omit the fields from the response we get when asking for the
   627  		// current state. If we _are_ making a request but it has no real
   628  		// effect, this is identical to making no request for restriction.
   629  		return request.Users == nil && request.Teams == nil
   630  	case state != nil && request != nil:
   631  		return equalTeams(state.Teams, request.Teams) && equalUsers(state.Users, request.Users)
   632  	default:
   633  		return false
   634  	}
   635  }
   636  
   637  func equalRestrictions(state *github.Restrictions, request *github.RestrictionsRequest) bool {
   638  	switch {
   639  	case state == nil && request == nil:
   640  		return true
   641  	case state == nil && request != nil:
   642  		// when there are no restrictions on apps, users or teams, GitHub will
   643  		// omit the fields from the response we get when asking for the
   644  		// current state. If we _are_ making a request but it has no real
   645  		// effect, this is identical to making no request for restriction.
   646  		return request.Apps == nil && request.Users == nil && request.Teams == nil
   647  	case state != nil && request != nil:
   648  		return equalApps(state.Apps, request.Apps) && equalTeams(state.Teams, request.Teams) && equalUsers(state.Users, request.Users)
   649  	default:
   650  		return false
   651  	}
   652  }
   653  
   654  func equalApps(stateApps []github.App, requestApps *[]string) bool {
   655  	var apps []string
   656  	for _, app := range stateApps {
   657  		// RestrictionsRequests record the app by slug, not name
   658  		apps = append(apps, app.Slug)
   659  	}
   660  	// Treat unspecified Apps configuration as no change that we do not create a breaking change when introducing Apps in branchprotector
   661  	// TODO: could be changed when "enableAppsRestrictions" flag is not needed anymore
   662  	return equalStringSlices(&apps, requestApps) || requestApps == nil
   663  }
   664  
   665  func equalTeams(stateTeams []github.Team, requestTeams *[]string) bool {
   666  	var teams []string
   667  	for _, team := range stateTeams {
   668  		// RestrictionsRequests record the team by slug, not name
   669  		teams = append(teams, team.Slug)
   670  	}
   671  	return equalStringSlices(&teams, requestTeams)
   672  }
   673  
   674  func equalUsers(stateUsers []github.User, requestUsers *[]string) bool {
   675  	var users []string
   676  	for _, user := range stateUsers {
   677  		users = append(users, github.NormLogin(user.Login))
   678  	}
   679  	var requestUsersNorm []string
   680  	if requestUsers != nil {
   681  		for _, user := range *requestUsers {
   682  			requestUsersNorm = append(requestUsersNorm, github.NormLogin(user))
   683  		}
   684  	}
   685  	return equalStringSlices(&users, &requestUsersNorm)
   686  }