sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/statusreconciler/migrator/migrator.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 migrator
    18  
    19  import (
    20  	"fmt"
    21  
    22  	"github.com/golang/glog"
    23  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    24  
    25  	"sigs.k8s.io/prow/pkg/github"
    26  )
    27  
    28  var (
    29  	stateAny = "ANY_STATE"
    30  	stateDNE = "DOES_NOT_EXIST"
    31  )
    32  
    33  // contextCondition is a struct that describes a condition about the state or existence of a context.
    34  type contextCondition struct {
    35  	// context is the status context that this condition applies to.
    36  	context string
    37  	// state is the status state that the condition accepts, or one of the special values "ANY_STATE"
    38  	// and "DOES_NOT_EXIST".
    39  	state string
    40  }
    41  
    42  // Mode is a struct that describes the behavior of a status migration. The behavior is described as
    43  // a list of conditions and a function that determines the actions to be taken when the conditions
    44  // are met.
    45  type Mode struct {
    46  	conditions []*contextCondition
    47  	// actions returns the status updates to make based on the current statuses and the sha.
    48  	// When actions is called, the Mode may assume that it's conditions are met.
    49  	actions func(statuses []github.Status, sha string) []github.Status
    50  }
    51  
    52  // MoveMode creates a mode that both copies and retires.
    53  // The mode creates a new context on every PR with the old context but not the new one, setting the
    54  // state of the new context to that of the old context before retiring the old context. A target URL
    55  // to describe why the old context was migrated can optionally be provided, as well.
    56  func MoveMode(origContext, newContext, targetURL string) *Mode {
    57  	dup := copyAction(origContext, newContext)
    58  	dep := retireAction(origContext, newContext, targetURL)
    59  
    60  	return &Mode{
    61  		conditions: []*contextCondition{
    62  			{context: origContext, state: stateAny},
    63  			{context: newContext, state: stateDNE},
    64  		},
    65  		actions: func(statuses []github.Status, sha string) []github.Status {
    66  			return append(dup(statuses, sha), dep(statuses, sha)...)
    67  		},
    68  	}
    69  }
    70  
    71  // CopyMode makes a mode that creates a new context in every PR that has the old context, but not the new one.
    72  // The state, description and target URL of the new context are made the same as those of the old context.
    73  func CopyMode(origContext, newContext string) *Mode {
    74  	return &Mode{
    75  		conditions: []*contextCondition{
    76  			{context: origContext, state: stateAny},
    77  			{context: newContext, state: stateDNE},
    78  		},
    79  		actions: copyAction(origContext, newContext),
    80  	}
    81  }
    82  
    83  // RetireMode creates a mode that retires an old context on all PRs.
    84  // If newContext is the empty string, origContext is retired without replacement. Its state is set to
    85  // 'success' and its description is set to indicate that the context is retired.
    86  // If newContext is not the empty string it is considered the replacement of origContext. This means
    87  // that only PRs that have the newContext in addition to the origContext will be considered and the
    88  // description of the retired context will indicate that it was replaced by newContext. A target URL
    89  // to describe why the old context was migrated can optionally be provided, as well.
    90  func RetireMode(origContext, newContext, targetURL string) *Mode {
    91  	conditions := []*contextCondition{{context: origContext, state: stateAny}}
    92  	if newContext != "" {
    93  		conditions = append(conditions, &contextCondition{context: newContext, state: stateAny})
    94  	}
    95  	return &Mode{
    96  		conditions: conditions,
    97  		actions:    retireAction(origContext, newContext, targetURL),
    98  	}
    99  }
   100  
   101  // copyAction creates a function that returns a copy action.
   102  // Specifically the returned function returns a RepoStatus that will create a status for newContext
   103  // with state set to the state of origContext.
   104  func copyAction(origContext, newContext string) func(statuses []github.Status, sha string) []github.Status {
   105  	return func(statuses []github.Status, sha string) []github.Status {
   106  		var oldStatus github.Status
   107  		var found bool
   108  		for _, status := range statuses {
   109  			if status.Context == origContext {
   110  				oldStatus = status
   111  				found = true
   112  				break
   113  			}
   114  		}
   115  		if !found {
   116  			// This means the conditions were not met! Should never have called this function, but it is a recoverable error.
   117  			glog.Error("failed to find original context in status list thus conditions for this duplicate action were not met. This should never happen!")
   118  			return nil
   119  		}
   120  		return []github.Status{
   121  			{
   122  				Context:     newContext,
   123  				State:       oldStatus.State,
   124  				TargetURL:   oldStatus.TargetURL,
   125  				Description: oldStatus.Description,
   126  			},
   127  		}
   128  	}
   129  }
   130  
   131  // retireAction creates a function that returns a retire action.
   132  // Specifically the returned function returns a RepoStatus that will update the origContext status
   133  // to 'success' and set it's description to mark it as retired and replaced by newContext.
   134  // If a non-empty URL is provided to describe why the context was retired, it will be
   135  // set as the target URL for the context.
   136  func retireAction(origContext, newContext, targetURL string) func(statuses []github.Status, sha string) []github.Status {
   137  	stateSuccess := "success"
   138  	var desc string
   139  	if newContext == "" {
   140  		desc = "Context retired without replacement."
   141  	} else {
   142  		desc = fmt.Sprintf("Context retired. Status moved to \"%s\".", newContext)
   143  	}
   144  	return func(statuses []github.Status, sha string) []github.Status {
   145  		return []github.Status{
   146  			{
   147  				Context:     origContext,
   148  				State:       stateSuccess,
   149  				TargetURL:   targetURL,
   150  				Description: desc,
   151  			},
   152  		}
   153  	}
   154  }
   155  
   156  // processStatuses checks the mode against the combined status of a PR and emits the actions to take.
   157  func (m Mode) processStatuses(combStatus *github.CombinedStatus) []github.Status {
   158  	for _, cond := range m.conditions {
   159  		var match github.Status
   160  		var found bool
   161  		for _, status := range combStatus.Statuses {
   162  			if status.Context == "" {
   163  				glog.Errorf("a status context for SHA ref '%s' had an empty Context field.", combStatus.SHA)
   164  				continue
   165  			}
   166  			if status.Context == cond.context {
   167  				match = status
   168  				found = true
   169  				break
   170  			}
   171  		}
   172  
   173  		switch cond.state {
   174  		case stateDNE:
   175  			if found {
   176  				return nil
   177  			}
   178  		case stateAny:
   179  			if !found {
   180  				return nil
   181  			}
   182  		default:
   183  			// Looking for a specific state in this case.
   184  			if !found {
   185  				// Did not find the context.
   186  				return nil
   187  			}
   188  			if match.State == "" {
   189  				glog.Errorf("context '%s' of SHA ref '%s' has an empty state.", cond.context, combStatus.SHA)
   190  				return nil
   191  			}
   192  			if match.State != cond.state {
   193  				// Context had a different state than what the condition requires.
   194  				return nil
   195  			}
   196  		}
   197  	}
   198  	return m.actions(combStatus.Statuses, combStatus.SHA)
   199  }
   200  
   201  type githubClient interface {
   202  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
   203  	CreateStatus(org, repo, SHA string, s github.Status) error
   204  	GetPullRequests(org, repo string) ([]github.PullRequest, error)
   205  }
   206  
   207  // Migrator will search github for PRs with a given context and migrate/retire/move them.
   208  type Migrator struct {
   209  	org  string
   210  	repo string
   211  
   212  	targetBranchFilter func(string) bool
   213  
   214  	continueOnError bool
   215  
   216  	client githubClient
   217  	Mode
   218  }
   219  
   220  // New creates a new migrator with specified options and client.
   221  func New(mode Mode, client github.Client, org, repo string, targetBranchFilter func(string) bool, continueOnError bool) *Migrator {
   222  	return &Migrator{
   223  		org:                org,
   224  		repo:               repo,
   225  		targetBranchFilter: targetBranchFilter,
   226  		continueOnError:    continueOnError,
   227  		client:             client,
   228  		Mode:               mode,
   229  	}
   230  }
   231  
   232  func (m *Migrator) processPR(pr github.PullRequest) error {
   233  	if !m.targetBranchFilter(pr.Base.Ref) {
   234  		return nil
   235  	}
   236  
   237  	combined, err := m.client.GetCombinedStatus(m.org, m.repo, pr.Head.SHA)
   238  	if err != nil {
   239  		return err
   240  	}
   241  	actions := m.processStatuses(combined)
   242  
   243  	for _, action := range actions {
   244  		if err := m.client.CreateStatus(m.org, m.repo, pr.Head.SHA, action); err != nil {
   245  			return err
   246  		}
   247  	}
   248  	return nil
   249  }
   250  
   251  // Migrate will retire/migrate/copy statuses for all matching PRs.
   252  func (m *Migrator) Migrate() error {
   253  	prs, err := m.client.GetPullRequests(m.org, m.repo)
   254  	if err != nil {
   255  		return err
   256  	}
   257  
   258  	var errors []error
   259  	for _, pr := range prs {
   260  		if err := m.processPR(pr); err != nil {
   261  			if m.continueOnError {
   262  				errors = append(errors, err)
   263  				continue
   264  			}
   265  			return err
   266  		}
   267  	}
   268  	return utilerrors.NewAggregate(errors)
   269  }