github.com/jenkins-x/jx/v2@v2.1.155/pkg/tekton/pipeline_info.go (about)

     1  package tekton
     2  
     3  import (
     4  	"fmt"
     5  	"regexp"
     6  	"sort"
     7  	"strconv"
     8  	"strings"
     9  	"time"
    10  
    11  	v1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1"
    12  	"github.com/jenkins-x/jx-logging/pkg/log"
    13  	"github.com/jenkins-x/jx/v2/pkg/builds"
    14  	"github.com/jenkins-x/jx/v2/pkg/gits"
    15  	"github.com/jenkins-x/jx/v2/pkg/kube"
    16  	"github.com/jenkins-x/jx/v2/pkg/tekton/syntax"
    17  	"github.com/jenkins-x/jx/v2/pkg/util"
    18  	"github.com/pkg/errors"
    19  	"github.com/tektoncd/pipeline/pkg/apis/pipeline"
    20  	tektonv1alpha1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
    21  	corev1 "k8s.io/api/core/v1"
    22  	knativeapis "knative.dev/pkg/apis"
    23  )
    24  
    25  // PipelineRunInfo provides information on a PipelineRun and its stages for use in getting logs and populating activity
    26  type PipelineRunInfo struct {
    27  	Name              string
    28  	Organisation      string
    29  	Repository        string
    30  	Branch            string
    31  	Context           string
    32  	Build             string
    33  	BuildNumber       int
    34  	Pipeline          string
    35  	PipelineRun       string
    36  	LastCommitSHA     string
    37  	BaseSHA           string
    38  	LastCommitMessage string
    39  	LastCommitURL     string
    40  	GitURL            string
    41  	GitInfo           *gits.GitRepository
    42  	Stages            []*StageInfo
    43  	Type              string
    44  	CreatedTime       time.Time
    45  }
    46  
    47  // StageInfo provides information on a particular stage, including its pod info or info on its nested stages
    48  type StageInfo struct {
    49  	// TODO: For now, we're not including git info - we're going to assume we have the same git info for the whole
    50  	// pipeline.
    51  	Name string
    52  
    53  	// These fields will populated for all non-parent stages
    54  	PodName        string
    55  	Task           string
    56  	TaskRun        string
    57  	FirstStepImage string
    58  	CreatedTime    time.Time
    59  	Pod            *corev1.Pod
    60  
    61  	// These fields will only be populated for appropriate parent stages
    62  	Parallel []*StageInfo
    63  	Stages   []*StageInfo
    64  
    65  	// This field will be non-empty if this is a nested stage, containing a list of  the names of all its parent stages with the top-level parent first
    66  	Parents []string
    67  }
    68  
    69  // GetStageNameIncludingParents constructs a full stage name including its parents, if they exist.
    70  func (si *StageInfo) GetStageNameIncludingParents() string {
    71  	if si.Name != "" {
    72  		return strings.NewReplacer("-", " ").Replace(strings.Join(append(si.Parents, si.Name), " / "))
    73  	}
    74  	return si.PodName
    75  }
    76  
    77  // PipelineRunInfoFilter allows specifying criteria on which to filter a list of PipelineRunInfos
    78  type PipelineRunInfoFilter struct {
    79  	Owner      string
    80  	Repository string
    81  	Branch     string
    82  	Build      string
    83  	Filter     string
    84  	Pending    bool
    85  	Context    string
    86  }
    87  
    88  // GetBuild gets the build identifier
    89  func (pri PipelineRunInfo) GetBuild() string {
    90  	return pri.Build
    91  }
    92  
    93  // GetOrderedTaskStages gets all the stages in this pipeline which actually contain a Task, in rough execution order
    94  // TODO: Handle parallelism better, where execution is not a straight line.
    95  func (pri *PipelineRunInfo) GetOrderedTaskStages() []*StageInfo {
    96  	var stages []*StageInfo
    97  
    98  	for _, n := range pri.Stages {
    99  		stages = append(stages, n.getOrderedTaskStagesForStage()...)
   100  	}
   101  
   102  	return stages
   103  }
   104  
   105  func (si *StageInfo) getOrderedTaskStagesForStage() []*StageInfo {
   106  	// If this is a Task Stage, not a parent Stage, return itself
   107  	if si.Task != "" {
   108  		return []*StageInfo{si}
   109  	}
   110  
   111  	var stages []*StageInfo
   112  
   113  	if len(si.Stages) > 0 {
   114  		for _, n := range si.Stages {
   115  			stages = append(stages, n.getOrderedTaskStagesForStage()...)
   116  		}
   117  	}
   118  
   119  	if len(si.Parallel) > 0 {
   120  		for _, n := range si.Parallel {
   121  			stages = append(stages, n.getOrderedTaskStagesForStage()...)
   122  		}
   123  	}
   124  
   125  	return stages
   126  }
   127  
   128  // CreatePipelineRunInfo looks up the PipelineRun for a given name and creates the PipelineRunInfo for it
   129  func CreatePipelineRunInfo(prName string, podList *corev1.PodList, ps *v1.PipelineStructure, pr *tektonv1alpha1.PipelineRun) (*PipelineRunInfo, error) {
   130  	branch := ""
   131  	lastCommitSha := ""
   132  	lastCommitMessage := ""
   133  	lastCommitURL := ""
   134  	owner := ""
   135  	repo := ""
   136  	build := ""
   137  	pullRefs := ""
   138  	pullBaseSha := ""
   139  	pullPullSha := ""
   140  	shaFromGitInit := ""
   141  	shaRegexp, err := regexp.Compile("\b[a-z0-9]{40}\b")
   142  	if err != nil {
   143  		log.Logger().Warnf("Failed to compile regexp because %s", err)
   144  	}
   145  	gitURL := ""
   146  
   147  	if pr == nil {
   148  		return nil, errors.New(fmt.Sprintf("PipelineRun %s cannot be found", prName))
   149  	}
   150  
   151  	pipelineType := BuildPipeline
   152  
   153  	if strings.HasPrefix(pr.Name, MetaPipeline.String()+"-") {
   154  		pipelineType = MetaPipeline
   155  	}
   156  
   157  	pri := &PipelineRunInfo{
   158  		Name:        possiblyUniquePipelineResourceName(pr.Labels[LabelOwner], pr.Labels[LabelRepo], pr.Labels[LabelBranch], pr.Labels[LabelContext], pr.Labels[LabelType], false) + "-" + pr.Labels[LabelBuild],
   159  		PipelineRun: pr.Name,
   160  		Pipeline:    pr.Spec.PipelineRef.Name,
   161  		Type:        pipelineType.String(),
   162  		CreatedTime: pr.CreationTimestamp.Time,
   163  	}
   164  
   165  	var pod *corev1.Pod
   166  
   167  	prStatus := pr.Status.GetCondition(knativeapis.ConditionSucceeded)
   168  	if err := pri.SetPodsForPipelineRun(podList, ps); err != nil {
   169  		return nil, errors.Wrapf(err, "Failure populating stages and pods for PipelineRun %s", prName)
   170  	}
   171  
   172  	pod = pri.FindFirstStagePod()
   173  
   174  	if pod == nil {
   175  		if prStatus != nil && prStatus.Status == corev1.ConditionUnknown {
   176  			return nil, errors.New(fmt.Sprintf("Couldn't find a Stage with steps for PipelineRun %s", prName))
   177  		}
   178  		// Just return nil if the pipeline run is completed and its pods have been GCed
   179  		return nil, nil
   180  	}
   181  
   182  	if pod.Labels != nil {
   183  		pri.Context = pod.Labels[LabelContext]
   184  	}
   185  	containers, _, isInit := kube.GetContainersWithStatusAndIsInit(pod)
   186  	for _, c := range containers {
   187  		container := c
   188  		// We historically used the git source step automatically injected by Tekton to get the git URL and the sha or
   189  		// branch being built, but that is no longer going to always be accurate due to our bespoke git merge step
   190  		// handling checkout/merging of PR branches into the target branch.
   191  		// The Prow/Lighthouse provided environment variables are a better source of truth, but we preserve this logic
   192  		// for now in case of edge cases, as a fallback.
   193  		if strings.HasPrefix(container.Name, "build-step-git-source") || strings.HasPrefix(container.Name, "step-git-source") {
   194  			_, args := kube.GetCommandAndArgs(&container, isInit)
   195  			for i := 0; i <= len(args)-2; i += 2 {
   196  				key := args[i]
   197  				value := args[i+1]
   198  
   199  				switch key {
   200  				case "-url":
   201  					gitURL = value
   202  				case "-revision":
   203  					if shaRegexp.MatchString(value) {
   204  						shaFromGitInit = value
   205  					} else {
   206  						branch = value
   207  					}
   208  				}
   209  			}
   210  		}
   211  		for _, v := range container.Env {
   212  			if v.Value == "" {
   213  				continue
   214  			}
   215  			// PULL_PULL_SHA is set by Prow/Lighthouse with the HEAD SHA of the PR being built, or the HEAD SHA of the
   216  			// first PR in a batch. It will be empty for non-PR builds.
   217  			if v.Name == "PULL_PULL_SHA" {
   218  				pullPullSha = v.Value
   219  			}
   220  			// PULL_BASE_SHA is set by Prow/Lighthouse with the target SHA for a PR build, or with just the HEAD SHA for
   221  			// master. It is always set.
   222  			if v.Name == "PULL_BASE_SHA" {
   223  				pullBaseSha = v.Value
   224  			}
   225  			// BRANCH_NAME is set by Prow/Lighthouse with the branch name being built - either PR-123 or master in almost
   226  			// all cases. It is always set.
   227  			if v.Name == util.EnvVarBranchName {
   228  				branch = v.Value
   229  			}
   230  			// REPO_OWNER is set by Prow/Lighthouse with the org or user the repo is under on the SCM provider. It is
   231  			// always set.
   232  			if v.Name == "REPO_OWNER" {
   233  				owner = v.Value
   234  			}
   235  			// REPO_NAME is set by Prow/Lighthouse with the repo name. It is always set.
   236  			if v.Name == "REPO_NAME" {
   237  				repo = v.Value
   238  			}
   239  			// Deprecated - this is only set for static masters.
   240  			if v.Name == "JX_BUILD_NUMBER" {
   241  				build = v.Value
   242  			}
   243  			// SOURCE_URL is set by Prow/Lighthouse with the clone URL for the repo. It is always set.
   244  			if v.Name == "SOURCE_URL" && gitURL == "" {
   245  				gitURL = v.Value
   246  			}
   247  			// PULL_REFS is set by Prow/Lighthouse with a comma-separated list of colon-delimited pairs of branch:ref
   248  			// involved in the build. For PRs, it will be "master:...,1:...", for batch builds, it will be "master:...,1:...,2:...",
   249  			// and for release builds, it will be "master:...". It is always set.
   250  			if v.Name == "PULL_REFS" && pullRefs == "" {
   251  				pullRefs = v.Value
   252  			}
   253  		}
   254  		if branch == "" {
   255  			for _, v := range container.Env {
   256  				// PULL_BASE_REF is set by Prow/Lighthouse to the branch or ref name the PR is targeting, like "master".
   257  				// It is only set for PR and batch builds.
   258  				if v.Name == "PULL_BASE_REF" {
   259  					build = v.Value
   260  				}
   261  			}
   262  		}
   263  		if build == "" {
   264  			for _, v := range container.Env {
   265  				// BUILD_NUMBER is set by the metapipeline. This used to also look at BUILD_ID, but we don't set that
   266  				// any more.
   267  				if v.Name == "BUILD_NUMBER" {
   268  					build = v.Value
   269  				}
   270  			}
   271  		}
   272  	}
   273  
   274  	if pullBaseSha != "" {
   275  		pri.BaseSHA = pullBaseSha
   276  	}
   277  
   278  	if pullPullSha != "" {
   279  		lastCommitSha = pullPullSha
   280  	}
   281  
   282  	// If we have the PULL_REFS env var, fall back on it for the lastCommitSha and base sha.
   283  	if pullRefs != "" {
   284  		splitRefs := strings.Split(pullRefs, ",")
   285  		// If there's at least one entry, use the first entry for the base sha.
   286  		if len(splitRefs) > 0 {
   287  			if pri.BaseSHA == "" {
   288  				pri.BaseSHA = shaFromPullRefEntry(splitRefs[0])
   289  			}
   290  		}
   291  		// If there are at least two entries, use the second entry for the lastCommitSha
   292  		if len(splitRefs) > 1 {
   293  			if lastCommitSha == "" {
   294  				// If we don't have a lastCommitSha, use the second ref
   295  				lastCommitSha = shaFromPullRefEntry(splitRefs[1])
   296  			}
   297  		}
   298  	}
   299  
   300  	// Fall back on git checkout if for some reason the last commit sha wasn't set based on env vars.
   301  	if lastCommitSha == "" {
   302  		lastCommitSha = shaFromGitInit
   303  	}
   304  
   305  	if build == "" {
   306  		build = builds.GetBuildNumberFromLabels(pr.Labels)
   307  	}
   308  	if build == "" {
   309  		build = "1"
   310  	}
   311  	buildNumber, err := strconv.Atoi(build)
   312  	if err != nil {
   313  		buildNumber = 1
   314  	}
   315  
   316  	pri.Build = build
   317  	pri.BuildNumber = buildNumber
   318  	pri.Branch = branch
   319  	if gitURL != "" {
   320  		gitInfo, err := gits.ParseGitURL(gitURL)
   321  		if err != nil {
   322  			return nil, errors.Wrapf(err, "Failed to parse Git URL %s", gitURL)
   323  		}
   324  		if owner == "" {
   325  			owner = gitInfo.Organisation
   326  		}
   327  		if repo == "" {
   328  			repo = gitInfo.Name
   329  		}
   330  		pri.GitInfo = gitInfo
   331  		pri.Pipeline = owner + "/" + repo + "/" + branch
   332  		pri.Name = owner + "-" + repo + "-" + branch + "-" + build
   333  		pri.Organisation = owner
   334  		pri.Repository = repo
   335  		pri.GitURL = gitURL
   336  		pri.LastCommitMessage = lastCommitMessage
   337  		pri.LastCommitSHA = lastCommitSha
   338  		pri.LastCommitURL = lastCommitURL
   339  	}
   340  	return pri, nil
   341  }
   342  
   343  // shaFromPullRefEntry returns the sha from "some-ref-name-or-pr-number:01234567890abcdef...", returning an empty string
   344  // if the entry isn't in that format.
   345  func shaFromPullRefEntry(refEntry string) string {
   346  	idx := strings.LastIndex(refEntry, ":")
   347  	if idx > 0 {
   348  		return refEntry[idx+1:]
   349  	}
   350  	return ""
   351  }
   352  
   353  // SetPodsForPipelineRun populates the pods for all stages within its PipelineRunInfo
   354  func (pri *PipelineRunInfo) SetPodsForPipelineRun(podList *corev1.PodList, ps *v1.PipelineStructure) error {
   355  	if pri.PipelineRun == "" {
   356  		return errors.New("No PipelineRun specified")
   357  	}
   358  
   359  	if ps == nil {
   360  		return errors.New(fmt.Sprintf("Could not find PipelineStructure for PipelineRun %s", pri.PipelineRun))
   361  	}
   362  
   363  	pscs := ps.GetAllStagesAndChildren()
   364  
   365  	var firstTaskStage *StageInfo
   366  
   367  	for _, psc := range pscs {
   368  		pri.Stages = append(pri.Stages, stageAndChildrenToStageInfo(psc, []string{}))
   369  	}
   370  
   371  	for _, si := range pri.Stages {
   372  		if firstTaskStage == nil {
   373  			firstTaskStage = si
   374  		}
   375  		if err := si.SetPodsForStageInfo(podList, pri.PipelineRun); err != nil {
   376  			return errors.Wrapf(err, "Couldn't populate Pods for Stages")
   377  		}
   378  	}
   379  
   380  	return nil
   381  }
   382  
   383  // SetPodsForStageInfo populates the pods for a particular stage and/or its children
   384  func (si *StageInfo) SetPodsForStageInfo(podList *corev1.PodList, prName string) error {
   385  	var podListItems []corev1.Pod
   386  
   387  	for _, p := range podList.Items {
   388  		if p.Labels[syntax.LabelStageName] == syntax.MangleToRfc1035Label(si.Name, "") && p.Labels[pipeline.GroupName+pipeline.PipelineRunLabelKey] == prName {
   389  			podListItems = append(podListItems, p)
   390  		}
   391  	}
   392  
   393  	if si.Task != "" {
   394  		if len(podListItems) == 0 {
   395  			// TODO: Probably the pod just hasn't started yet, so return nil
   396  			return nil
   397  		}
   398  		if len(podListItems) > 1 {
   399  			return errors.New(fmt.Sprintf("Too many Pods (%d) found for PipelineRun %s and Stage %s", len(podListItems), prName, si.Name))
   400  		}
   401  		pod := podListItems[0]
   402  		si.PodName = pod.Name
   403  		si.Task = pod.Labels[builds.LabelTaskName]
   404  		si.TaskRun = pod.Labels[builds.LabelTaskRunName]
   405  		si.Pod = &pod
   406  		si.CreatedTime = pod.CreationTimestamp.Time
   407  		containers, _, isInit := kube.GetContainersWithStatusAndIsInit(&pod)
   408  		if isInit && len(containers) > 2 {
   409  			si.FirstStepImage = containers[2].Image
   410  		} else if !isInit && len(containers) > 1 {
   411  			si.FirstStepImage = containers[1].Image
   412  		}
   413  	} else if len(si.Stages) > 0 {
   414  		for _, child := range si.Stages {
   415  			if err := child.SetPodsForStageInfo(podList, prName); err != nil {
   416  				return err
   417  			}
   418  		}
   419  	} else if len(si.Parallel) > 0 {
   420  		for _, child := range si.Parallel {
   421  			if err := child.SetPodsForStageInfo(podList, prName); err != nil {
   422  				return err
   423  			}
   424  		}
   425  	}
   426  
   427  	return nil
   428  }
   429  
   430  // FindFirstStagePod finds the first stage in this pipeline run to have a pod, and then returns its pod
   431  func (pri *PipelineRunInfo) FindFirstStagePod() *corev1.Pod {
   432  	for _, s := range pri.Stages {
   433  		found := s.findTaskStageInfo()
   434  		if found != nil {
   435  			return found.Pod
   436  		}
   437  	}
   438  	return nil
   439  }
   440  
   441  // findTaskStageInfo gets the first stage that should actually have a pod created for it
   442  func (si *StageInfo) findTaskStageInfo() *StageInfo {
   443  	if si.Task != "" {
   444  		return si
   445  	}
   446  	for _, s := range si.Parallel {
   447  		child := s.findTaskStageInfo()
   448  		if child != nil {
   449  			return child
   450  		}
   451  	}
   452  	for _, s := range si.Stages {
   453  		child := s.findTaskStageInfo()
   454  		if child != nil {
   455  			return child
   456  		}
   457  	}
   458  
   459  	return nil
   460  }
   461  
   462  // GetFullChildStageNames gets the fully qualified (i.e., with parents appended) names of each stage underneath this one.
   463  func (si *StageInfo) GetFullChildStageNames(includeSelf bool) []string {
   464  	if si.Task != "" && includeSelf {
   465  		return []string{si.GetStageNameIncludingParents()}
   466  	}
   467  
   468  	var names []string
   469  	for _, n := range si.Parallel {
   470  		names = append(names, n.GetFullChildStageNames(true)...)
   471  	}
   472  	for _, n := range si.Stages {
   473  		names = append(names, n.GetFullChildStageNames(true)...)
   474  	}
   475  
   476  	return names
   477  }
   478  
   479  func stageAndChildrenToStageInfo(psc *v1.PipelineStageAndChildren, parents []string) *StageInfo {
   480  	si := &StageInfo{
   481  		Name:    psc.Stage.Name,
   482  		Parents: parents,
   483  	}
   484  	if psc.Stage.TaskRef != nil {
   485  		si.Task = *psc.Stage.TaskRef
   486  	}
   487  
   488  	for _, s := range psc.Stages {
   489  		stage := s
   490  		si.Stages = append(si.Stages, stageAndChildrenToStageInfo(&stage, append(parents, psc.Stage.Name)))
   491  	}
   492  
   493  	for _, s := range psc.Parallel {
   494  		stage := s
   495  		si.Parallel = append(si.Parallel, stageAndChildrenToStageInfo(&stage, append(parents, psc.Stage.Name)))
   496  	}
   497  
   498  	return si
   499  }
   500  
   501  // PipelineRunMatches returns true if the pipeline run info matches the filter
   502  func (o *PipelineRunInfoFilter) PipelineRunMatches(info *PipelineRunInfo) bool {
   503  	if o.Owner != "" && o.Owner != info.Organisation {
   504  		return false
   505  	}
   506  	if o.Repository != "" && o.Repository != info.Repository {
   507  		return false
   508  	}
   509  	if o.Branch != "" && strings.ToLower(o.Branch) != strings.ToLower(info.Branch) {
   510  		return false
   511  	}
   512  	if o.Build != "" && o.Build != info.Build {
   513  		return false
   514  	}
   515  	if o.Context != "" && o.Context != info.Context {
   516  		return false
   517  	}
   518  	if o.Filter != "" && !strings.Contains(info.Name, o.Filter) {
   519  		return false
   520  	}
   521  	if o.Pending {
   522  		status := info.Status()
   523  		if status != "Pending" && status != "Running" {
   524  			return false
   525  		}
   526  	}
   527  	return true
   528  }
   529  
   530  // BuildNumber returns the integer build number filter if specified
   531  func (o *PipelineRunInfoFilter) BuildNumber() int {
   532  	text := o.Build
   533  	if text != "" {
   534  		answer, err := strconv.Atoi(text)
   535  		if err != nil {
   536  			return answer
   537  		}
   538  	}
   539  	return 0
   540  }
   541  
   542  // MatchesPipeline returns true if this build info matches the given pipeline
   543  func (pri *PipelineRunInfo) MatchesPipeline(activity *v1.PipelineActivity) bool {
   544  	d := kube.CreatePipelineDetails(activity)
   545  	if d == nil {
   546  		return false
   547  	}
   548  	return d.GitOwner == pri.Organisation && d.GitRepository == pri.Repository && d.Build == pri.Build && strings.ToLower(d.BranchName) == strings.ToLower(pri.Branch) && d.Context == pri.Context
   549  }
   550  
   551  // Status returns the build status
   552  func (pri *PipelineRunInfo) Status() string {
   553  	pod := pri.FindFirstStagePod()
   554  	if pod == nil {
   555  		return "No Pod"
   556  	}
   557  	return string(pod.Status.Phase)
   558  }
   559  
   560  // ToBuildPodInfo converts the object into a BuildPodInfo so it can be easily filtered
   561  func (pri PipelineRunInfo) ToBuildPodInfo() *builds.BuildPodInfo {
   562  	answer := &builds.BuildPodInfo{
   563  		Name:              pri.Name,
   564  		Organisation:      pri.Organisation,
   565  		Repository:        pri.Repository,
   566  		Branch:            pri.Branch,
   567  		Build:             pri.Build,
   568  		BuildNumber:       pri.BuildNumber,
   569  		Context:           pri.Context,
   570  		Pipeline:          pri.Pipeline,
   571  		LastCommitSHA:     pri.LastCommitSHA,
   572  		LastCommitURL:     pri.LastCommitURL,
   573  		LastCommitMessage: pri.LastCommitMessage,
   574  		GitInfo:           pri.GitInfo,
   575  	}
   576  	pod := pri.FindFirstStagePod()
   577  	if pod != nil {
   578  		answer.Pod = pod
   579  		answer.PodName = pod.Name
   580  		containers := pod.Spec.Containers
   581  		if len(containers) > 0 {
   582  			answer.FirstStepImage = containers[0].Image
   583  		}
   584  		answer.CreatedTime = pod.CreationTimestamp.Time
   585  	}
   586  	return answer
   587  }
   588  
   589  // PipelineRunInfoOrder allows sorting of a slice of PipelineRunInfos
   590  type PipelineRunInfoOrder []*PipelineRunInfo
   591  
   592  func (a PipelineRunInfoOrder) Len() int      { return len(a) }
   593  func (a PipelineRunInfoOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
   594  func (a PipelineRunInfoOrder) Less(i, j int) bool {
   595  	b1 := a[i]
   596  	b2 := a[j]
   597  	if b1.Organisation != b2.Organisation {
   598  		return b1.Organisation < b2.Organisation
   599  	}
   600  	if b1.Repository != b2.Repository {
   601  		return b1.Repository < b2.Repository
   602  	}
   603  	if b1.Branch != b2.Branch {
   604  		return b1.Branch < b2.Branch
   605  	}
   606  	return b1.BuildNumber > b2.BuildNumber
   607  }
   608  
   609  // SortPipelineRunInfos sorts a slice of PipelineRunInfos by their org, repo, branch, and build number
   610  func SortPipelineRunInfos(pris []*PipelineRunInfo) {
   611  	sort.Sort(PipelineRunInfoOrder(pris))
   612  }