github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/crier/reporters/gerrit/reporter.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 reporter implements a reporter interface for gerrit
    18  package gerrit
    19  
    20  import (
    21  	"context"
    22  	"errors"
    23  	"fmt"
    24  	"regexp"
    25  	"sort"
    26  	"strconv"
    27  	"strings"
    28  	"time"
    29  
    30  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    31  
    32  	"github.com/andygrunwald/go-gerrit"
    33  	"github.com/sirupsen/logrus"
    34  	ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
    35  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    36  
    37  	v1 "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    38  	"sigs.k8s.io/prow/pkg/config"
    39  	"sigs.k8s.io/prow/pkg/crier/reporters/criercommonlib"
    40  	"sigs.k8s.io/prow/pkg/gerrit/client"
    41  	"sigs.k8s.io/prow/pkg/kube"
    42  )
    43  
    44  const (
    45  	cross      = "❌"
    46  	tick       = "✔️"
    47  	hourglass  = "⏳"
    48  	prohibited = "🚫"
    49  
    50  	defaultProwHeader               = "Prow Status:"
    51  	jobReportFormat                 = "%s [%s](%s) %s\n"
    52  	jobReportFormatUrlNotFound      = "%s %s (URL_NOT_FOUND) %s\n"
    53  	jobReportFormatWithoutURL       = "%s %s %s\n"
    54  	jobReportFormatLegacyRegex      = `^(\S+) (\S+) (\S+) - (\S+)$`
    55  	jobReportFormatRegex            = `^(\S+) \[(\S+)\]\((\S+)\) (\S+)$`
    56  	jobReportFormatUrlNotFoundRegex = `^(\S+) (\S+) \(URL_NOT_FOUND\) (\S+)$`
    57  	jobReportFormatWithoutURLRegex  = `^(\S+) (\S+) (\S+)$`
    58  	errorLinePrefix                 = "NOTE FROM PROW"
    59  	// jobReportHeader expects 4 args. {defaultProwHeader}, {jobs-passed},
    60  	// {jobs-total}, {additional-text(optional)}.
    61  	jobReportHeader = "%s %d out of %d pjs passed! 👉 Comment `/retest` to rerun only failed tests (if any), or `/test all` to rerun all tests.%s\n"
    62  
    63  	// lgtm means all presubmits passed, but need someone else to approve before merge (looks good to me).
    64  	lgtm = "+1"
    65  	// lbtm means some presubmits failed, perfer not merge (looks bad to me).
    66  	lbtm = "-1"
    67  	// lztm is the minimum score for a postsubmit.
    68  	lztm = "0"
    69  	// codeReview is the default gerrit code review label
    70  	codeReview = client.CodeReview
    71  	// maxCommentSizeLimit is from
    72  	// http://gerrit-documentation.storage.googleapis.com/Documentation/3.2.0/config-gerrit.html#change.commentSizeLimit, where it says:
    73  	//
    74  	//    Maximum allowed size in characters of a regular (non-robot) comment.
    75  	//    Comments which exceed this size will be rejected. Size computation is
    76  	//    approximate and may be off by roughly 1%. Common unit suffixes of 'k',
    77  	//    'm', or 'g' are supported. The value must be positive.
    78  	//
    79  	//    The default limit is 16kiB.
    80  	//
    81  	// 16KiB = 16*1024 bytes. Note that the size computation is stated as
    82  	// **approximate** and can be off by about 1%. To be safe, we use 15*1024 or
    83  	// 93.75% of the default 16KiB limit. This value is lower than the limit by
    84  	// 6.25% to be 6x below the ~1% margin of error described by the Gerrit
    85  	// docs.
    86  	//
    87  	// Even assuming that the docs have their units wrong (maybe they actually
    88  	// mean 16KB = 16000, not 16KiB), the new value of (15*1024)/16000 = 0.96,
    89  	// or to be 4% less than the theoretical maximum, which is still a
    90  	// conservative figure.
    91  	maxCommentSizeLimit = 15 * 1024
    92  )
    93  
    94  var (
    95  	stateIcon = map[v1.ProwJobState]string{
    96  		v1.PendingState:   hourglass,
    97  		v1.TriggeredState: hourglass,
    98  		v1.SuccessState:   tick,
    99  		v1.FailureState:   cross,
   100  		v1.AbortedState:   prohibited,
   101  	}
   102  )
   103  
   104  type gerritClient interface {
   105  	SetReview(instance, id, revision, message string, labels map[string]string) error
   106  	GetChange(instance, id string, additionalFields ...string) (*gerrit.ChangeInfo, error)
   107  	ChangeExist(instance, id string) (bool, error)
   108  }
   109  
   110  // Client is a gerrit reporter client
   111  type Client struct {
   112  	gc          gerritClient
   113  	pjclientset ctrlruntimeclient.Client
   114  	prLocks     *criercommonlib.ShardedLock
   115  }
   116  
   117  // Job is the view of a prowjob scoped for a report
   118  type Job struct {
   119  	Name  string
   120  	State v1.ProwJobState
   121  	Icon  string
   122  	URL   string
   123  }
   124  
   125  // JobReport is the structured job report format
   126  type JobReport struct {
   127  	Jobs    []Job
   128  	Success int
   129  	Total   int
   130  	Message string
   131  	Header  string
   132  }
   133  
   134  // NewReporter returns a reporter client
   135  func NewReporter(orgRepoConfigGetter func() *config.GerritOrgRepoConfigs, cookiefilePath string, pjclientset ctrlruntimeclient.Client, maxQPS, maxBurst int) (*Client, error) {
   136  	// Initialize an empty client, the orgs/repos will be filled in by
   137  	// ApplyGlobalConfig later.
   138  	gc, err := client.NewClient(nil, maxQPS, maxBurst)
   139  	if err != nil {
   140  		return nil, err
   141  	}
   142  	// applyGlobalConfig reads gerrit configurations from global gerrit config,
   143  	// it will completely override previously configured gerrit hosts and projects.
   144  	// it will also by the way authenticate gerrit
   145  	gc.ApplyGlobalConfig(orgRepoConfigGetter, nil, cookiefilePath, "", func() {})
   146  
   147  	// Authenticate creates a goroutine for rotating token secrets when called the first
   148  	// time, afterwards it only authenticate once.
   149  	// applyGlobalConfig calls authenticate only when global gerrit config presents,
   150  	// call it here is required for cases where gerrit repos are defined as command
   151  	// line arg(which is going to be deprecated).
   152  	gc.Authenticate(cookiefilePath, "")
   153  
   154  	c := &Client{
   155  		gc:          gc,
   156  		pjclientset: pjclientset,
   157  		prLocks:     criercommonlib.NewShardedLock(),
   158  	}
   159  
   160  	c.prLocks.RunCleanup()
   161  	return c, nil
   162  }
   163  
   164  // GetName returns the name of the reporter
   165  func (c *Client) GetName() string {
   166  	return "gerrit-reporter"
   167  }
   168  
   169  // ShouldReport returns if this prowjob should be reported by the gerrit reporter
   170  func (c *Client) ShouldReport(ctx context.Context, log *logrus.Entry, pj *v1.ProwJob) bool {
   171  	if !pj.Spec.Report {
   172  		return false
   173  	}
   174  
   175  	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
   176  	defer cancel()
   177  
   178  	if pj.Status.State == v1.TriggeredState || pj.Status.State == v1.PendingState {
   179  		// not done yet
   180  		log.Info("PJ not finished")
   181  		return false
   182  	}
   183  
   184  	if pj.Status.State == v1.AbortedState {
   185  		// aborted (new patchset)
   186  		log.Info("PJ aborted")
   187  		return false
   188  	}
   189  
   190  	// has gerrit metadata (scheduled by gerrit adapter)
   191  	if pj.ObjectMeta.Annotations[kube.GerritID] == "" ||
   192  		pj.ObjectMeta.Annotations[kube.GerritInstance] == "" ||
   193  		pj.ObjectMeta.Labels[kube.GerritRevision] == "" {
   194  		log.Info("Not a gerrit job")
   195  		return false
   196  	}
   197  
   198  	// Don't wait for report aggregation if not voting on any label
   199  	if pj.ObjectMeta.Labels[kube.GerritReportLabel] == "" {
   200  		return true
   201  	}
   202  
   203  	// allPJsAgreeToReport is a helper function that queries all prowjobs based
   204  	// on provided labels and run each one through singlePJAgreeToReport,
   205  	// returns false if any of the prowjob doesn't agree.
   206  	allPJsAgreeToReport := func(labels []string, singlePJAgreeToReport func(pj *v1.ProwJob) bool) bool {
   207  		selector := map[string]string{}
   208  		for _, l := range labels {
   209  			selector[l] = pj.ObjectMeta.Labels[l]
   210  		}
   211  
   212  		var pjs v1.ProwJobList
   213  		if err := c.pjclientset.List(ctx, &pjs, ctrlruntimeclient.MatchingLabels(selector)); err != nil {
   214  			log.WithError(err).Errorf("Cannot list prowjob with selector %v", selector)
   215  			return false
   216  		}
   217  
   218  		for _, pjob := range pjs.Items {
   219  			if !singlePJAgreeToReport(&pjob) {
   220  				return false
   221  			}
   222  		}
   223  
   224  		return true
   225  	}
   226  
   227  	// patchsetNumFromPJ converts value of "prow.k8s.io/gerrit-patchset" to
   228  	// integer, the value is used for evaluating whether a newer patchset for
   229  	// current CR was already established. It may accidentally omit reporting if
   230  	// current prowjob doesn't have this label or has an invalid value, this
   231  	// will be reflected as warning message in prow.
   232  	patchsetNumFromPJ := func(pj *v1.ProwJob) int {
   233  		log := log.WithFields(logrus.Fields{"label": kube.GerritPatchset, "job": pj.Name})
   234  		ps, ok := pj.ObjectMeta.Labels[kube.GerritPatchset]
   235  		if !ok {
   236  			// This label exists only in jobs that are created by Gerrit. For jobs that are
   237  			// created by Pubsub it's entirely up to the users.
   238  			log.Debug("Label not found in prowjob.")
   239  			return -1
   240  		}
   241  		intPs, err := strconv.Atoi(ps)
   242  		if err != nil {
   243  			log.Debug("Found non integer label value in prowjob.")
   244  			return -1
   245  		}
   246  		return intPs
   247  	}
   248  
   249  	// Get patchset number from current pj.
   250  	patchsetNum := patchsetNumFromPJ(pj)
   251  
   252  	// Check all other prowjobs to see whether they agree or not
   253  	return allPJsAgreeToReport([]string{kube.GerritRevision, kube.ProwJobTypeLabel, kube.GerritReportLabel}, func(otherPj *v1.ProwJob) bool {
   254  		if otherPj.Status.State == v1.TriggeredState || otherPj.Status.State == v1.PendingState {
   255  			// other jobs with same label are still running on this revision, skip report
   256  			log.Info("Other jobs with same label are still running on this revision")
   257  			return false
   258  		}
   259  		return true
   260  	}) && allPJsAgreeToReport([]string{kube.OrgLabel, kube.RepoLabel, kube.PullLabel}, func(otherPj *v1.ProwJob) bool {
   261  		// This job has duplicate(s) and there are newer one(s)
   262  		if otherPj.Spec.Job == pj.Spec.Job && otherPj.CreationTimestamp.After(pj.CreationTimestamp.Time) {
   263  			return false
   264  		}
   265  		// Newer patchset exists, skip report
   266  		return patchsetNumFromPJ(otherPj) <= patchsetNum
   267  	})
   268  }
   269  
   270  // Report will send the current prowjob status as a gerrit review
   271  func (c *Client) Report(ctx context.Context, logger *logrus.Entry, pj *v1.ProwJob) ([]*v1.ProwJob, *reconcile.Result, error) {
   272  	logger = logger.WithFields(logrus.Fields{"job": pj.Spec.Job, "name": pj.Name})
   273  
   274  	// Gerrit reporter hasn't learned how to deduplicate itself from report yet,
   275  	// will need to block here. Unfortunately need to check after this section
   276  	// to ensure that the job was not already marked reported by other threads
   277  	// TODO(chaodaiG): postsubmit job technically doesn't know which PR it's
   278  	// from, currently it's associated with a PR in gerrit in a weird way, which
   279  	// needs to be fixed in
   280  	// https://github.com/kubernetes/test-infra/issues/22653, remove the
   281  	// PostsubmitJob check once it's fixed
   282  	if pj.Spec.Type == v1.PresubmitJob || pj.Spec.Type == v1.PostsubmitJob {
   283  		key, err := lockKeyForPJ(pj)
   284  		if err != nil {
   285  			return nil, nil, fmt.Errorf("failed to get lockkey for job: %w", err)
   286  		}
   287  		lock, err := c.prLocks.GetLock(ctx, *key)
   288  		if err != nil {
   289  			return nil, nil, err
   290  		}
   291  		if err := lock.Acquire(ctx, 1); err != nil {
   292  			return nil, nil, err
   293  		}
   294  		defer lock.Release(1)
   295  
   296  		// In the case where several prow jobs from the same PR are finished one
   297  		// after another, by the time the lock is acquired, this job might have
   298  		// already been reported by another worker, refetch this pj to make sure
   299  		// that no duplicate report is produced
   300  		pjObjKey := ctrlruntimeclient.ObjectKeyFromObject(pj)
   301  		if err := c.pjclientset.Get(ctx, pjObjKey, pj); err != nil {
   302  			if apierrors.IsNotFound(err) {
   303  				// Job could be GC'ed or deleted for other reasons, not to
   304  				// report, this is not a prow error and should not be retried
   305  				logger.Debug("object no longer exist")
   306  				return nil, nil, nil
   307  			}
   308  
   309  			return nil, nil, fmt.Errorf("failed to get prowjob %s: %w", pjObjKey.String(), err)
   310  		}
   311  		if pj.Status.PrevReportStates[c.GetName()] == pj.Status.State {
   312  			logger.Info("Already reported by other threads.")
   313  			return nil, nil, nil
   314  		}
   315  	}
   316  
   317  	newCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
   318  	defer cancel()
   319  
   320  	clientGerritRevision := kube.GerritRevision
   321  	clientGerritID := kube.GerritID
   322  	clientGerritInstance := kube.GerritInstance
   323  	pjTypeLabel := kube.ProwJobTypeLabel
   324  	gerritReportLabel := kube.GerritReportLabel
   325  
   326  	var pjsOnRevisionWithSameLabel v1.ProwJobList
   327  	var pjsToUpdateState []v1.ProwJob
   328  	var toReportJobs []*v1.ProwJob
   329  	if pj.ObjectMeta.Labels[gerritReportLabel] == "" && pj.Status.State != v1.AbortedState {
   330  		toReportJobs = append(toReportJobs, pj)
   331  		pjsToUpdateState = []v1.ProwJob{*pj}
   332  	} else { // generate an aggregated report
   333  
   334  		// list all prowjobs in the patchset matching pj's type (pre- or post-submit)
   335  		selector := map[string]string{
   336  			clientGerritRevision: pj.ObjectMeta.Labels[clientGerritRevision],
   337  			pjTypeLabel:          pj.ObjectMeta.Labels[pjTypeLabel],
   338  			gerritReportLabel:    pj.ObjectMeta.Labels[gerritReportLabel],
   339  		}
   340  
   341  		if err := c.pjclientset.List(newCtx, &pjsOnRevisionWithSameLabel, ctrlruntimeclient.MatchingLabels(selector)); err != nil {
   342  			logger.WithError(err).WithField("selector", selector).Errorf("Cannot list prowjob with selector")
   343  			return nil, nil, err
   344  		}
   345  
   346  		mostRecentJob := map[string]*v1.ProwJob{}
   347  		for idx, pjOnRevisionWithSameLabel := range pjsOnRevisionWithSameLabel.Items {
   348  			job, ok := mostRecentJob[pjOnRevisionWithSameLabel.Spec.Job]
   349  			if !ok || job.CreationTimestamp.Time.Before(pjOnRevisionWithSameLabel.CreationTimestamp.Time) {
   350  				mostRecentJob[pjOnRevisionWithSameLabel.Spec.Job] = &pjsOnRevisionWithSameLabel.Items[idx]
   351  			}
   352  			pjsToUpdateState = append(pjsToUpdateState, pjOnRevisionWithSameLabel)
   353  		}
   354  		for _, pjOnRevisionWithSameLabel := range mostRecentJob {
   355  			toReportJobs = append(toReportJobs, pjOnRevisionWithSameLabel)
   356  		}
   357  	}
   358  	report := GenerateReport(toReportJobs, 0)
   359  	message := report.Header + report.Message
   360  	// report back
   361  	gerritID := pj.ObjectMeta.Annotations[clientGerritID]
   362  	gerritInstance := pj.ObjectMeta.Annotations[clientGerritInstance]
   363  	gerritRevision := pj.ObjectMeta.Labels[clientGerritRevision]
   364  	logger = logger.WithFields(logrus.Fields{
   365  		"instance": gerritInstance,
   366  		"id":       gerritID,
   367  	})
   368  	var reportLabel string
   369  	if val, ok := pj.ObjectMeta.Labels[kube.GerritReportLabel]; ok {
   370  		reportLabel = val
   371  	} else {
   372  		reportLabel = codeReview
   373  	}
   374  
   375  	if report.Total <= 0 {
   376  		// Shouldn't happen but return if does
   377  		logger.Warn("Tried to report empty jobs.")
   378  		return nil, nil, nil
   379  	}
   380  	var reviewLabels map[string]string
   381  	var change *gerrit.ChangeInfo
   382  	var err error
   383  	if reportLabel != "" {
   384  		var vote string
   385  		// Can only vote below zero before merge
   386  		// TODO(fejta): cannot vote below previous vote after merge
   387  		switch {
   388  		case report.Success == report.Total:
   389  			vote = lgtm
   390  		case pj.Spec.Type == v1.PresubmitJob:
   391  			//https://gerrit-documentation.storage.googleapis.com/Documentation/3.1.4/config-labels.html#label_allowPostSubmit
   392  			// If presubmit and failure vote -1...
   393  			vote = lbtm
   394  
   395  			change, err = c.gc.GetChange(gerritInstance, gerritID)
   396  			if err != nil {
   397  				exist, existErr := c.gc.ChangeExist(gerritInstance, gerritID)
   398  				if existErr == nil && !exist {
   399  					// PR was deleted, no reason to report or retry
   400  					logger.WithError(err).Info("Change doesn't exist any more, skip reporting.")
   401  					return nil, nil, nil
   402  				}
   403  				logger.WithError(err).Warn("Unable to get change")
   404  			} else if change.Status == client.Merged {
   405  				// Unless change is already merged. Merged changes should not be voted <0
   406  				vote = lztm
   407  			}
   408  		default:
   409  			vote = lztm
   410  		}
   411  		reviewLabels = map[string]string{reportLabel: vote}
   412  	}
   413  
   414  	logger.Infof("Reporting to instance %s on id %s with message %s", gerritInstance, gerritID, message)
   415  	if err := c.gc.SetReview(gerritInstance, gerritID, gerritRevision, message, reviewLabels); err != nil {
   416  		logger.WithError(err).WithField("gerrit_id", gerritID).WithField("label", reportLabel).Info("Failed to set review.")
   417  
   418  		// It could be that the commit is deleted by the time we want to report.
   419  		// Swollow the error if this is the case.
   420  		exist, existErr := c.gc.ChangeExist(gerritInstance, gerritID)
   421  		if existErr == nil {
   422  			if !exist {
   423  				// PR was deleted, no reason to report or retry
   424  				logger.WithError(err).Info("Change doesn't exist any more, skip reporting.")
   425  				return nil, nil, nil
   426  			}
   427  			if change == nil {
   428  				var debugErr error
   429  				change, debugErr = c.gc.GetChange(gerritInstance, gerritID)
   430  				if debugErr != nil {
   431  					logger.WithError(debugErr).WithField("gerrit_id", gerritID).Info("Getting change failed. This is trying to help determine why SetReview failed.")
   432  				}
   433  			}
   434  		} else {
   435  			// Checking change exist error is not as useful as the error from
   436  			// SetReview, log it on debug level
   437  			logger.WithError(existErr).Debug("Failed checking existence of change.")
   438  		}
   439  		if change != nil {
   440  			// keys of `Revisions` are the revision strings, see
   441  			// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
   442  			if _, ok := change.Revisions[gerritRevision]; !ok {
   443  				logger.WithFields(logrus.Fields{"gerrit_id": gerritID, "revision": gerritRevision}).Info("The revision to be commented is missing, swallow error.")
   444  				// still want the rest of the function continue, so that all
   445  				// jobs for this revision are marked reported.
   446  				err = nil
   447  			}
   448  		}
   449  
   450  		if err != nil {
   451  			if reportLabel == "" {
   452  				return nil, nil, err
   453  			}
   454  			// Retry without voting on a label
   455  			message := fmt.Sprintf("[NOTICE]: Prow Bot cannot access %s label!\n%s", reportLabel, message)
   456  			if err := c.gc.SetReview(gerritInstance, gerritID, gerritRevision, message, nil); err != nil {
   457  				return nil, nil, err
   458  			}
   459  		}
   460  	}
   461  
   462  	logger.Infof("Review Complete, reported jobs: %s", jobNames(toReportJobs))
   463  
   464  	// If return here, the shardedLock will be released, and other threads that
   465  	// are from the same PR will still not understand that it's already
   466  	// reported, as the change of previous report state happens only after the
   467  	// returning of current function from the caller.
   468  	// Ideally the previous report state should be changed here.
   469  	// This operation takes a long time when there are a lot of jobs
   470  	// in the batch, so we are creating a new context.
   471  	loopCtx, loopCancel := context.WithTimeout(ctx, 2*time.Minute)
   472  	defer loopCancel()
   473  	logger.WithFields(logrus.Fields{
   474  		"job-count":      len(toReportJobs),
   475  		"all-jobs-count": len(pjsToUpdateState),
   476  	}).Info("Reported job(s), now will update pj(s).")
   477  	// All latest jobs for this label were already reported, none of the jobs
   478  	// for this label are worthy reporting any more. Mark all of them as
   479  	// reported to avoid corner cases where an older job finished later, and the
   480  	// newer prowjobs CRD was somehow missing from the cluster.
   481  	for _, pjob := range pjsToUpdateState {
   482  		if pjob.Status.State == v1.AbortedState || pjob.Status.PrevReportStates[c.GetName()] == pjob.Status.State {
   483  			continue
   484  		}
   485  		if err = criercommonlib.UpdateReportStateWithRetries(loopCtx, &pjob, logger, c.pjclientset, c.GetName()); err != nil {
   486  			logger.WithError(err).Error("Failed to update report state on prowjob")
   487  		}
   488  	}
   489  
   490  	// Let caller know that we are done with this job.
   491  	return nil, nil, err
   492  }
   493  
   494  func jobNames(jobs []*v1.ProwJob) []string {
   495  	names := make([]string, len(jobs))
   496  	for i, job := range jobs {
   497  		names[i] = fmt.Sprintf("%s, %s", job.Spec.Job, job.Name)
   498  	}
   499  	return names
   500  }
   501  
   502  func statusIcon(state v1.ProwJobState) string {
   503  	icon, ok := stateIcon[state]
   504  	if !ok {
   505  		return prohibited
   506  	}
   507  	return icon
   508  }
   509  
   510  // jobFromPJ extracts the minimum job information for the given ProwJob, to be
   511  // used by GenerateReport to create a textual report of it. It will be
   512  // serialized to a single line of text, with or without the URL depending on how
   513  // much room we have left against maxCommentSizeLimit. The reason why it is
   514  // serialized as a single line of text is because ParseReport uses newlines as a
   515  // token delimiter.
   516  func jobFromPJ(pj *v1.ProwJob) Job {
   517  	return Job{Name: pj.Spec.Job, State: pj.Status.State, Icon: statusIcon(pj.Status.State), URL: pj.Status.URL}
   518  }
   519  
   520  func (j *Job) serializeWithoutURL() string {
   521  	return fmt.Sprintf(jobReportFormatWithoutURL, j.Icon, j.Name, strings.ToUpper(string(j.State)))
   522  }
   523  
   524  func (j *Job) serialize() string {
   525  
   526  	// It may be that the URL is empty, so we have to take care not to link it
   527  	// as such if we're doing Markdown-flavored URLs. This can happen if the job
   528  	// has not been scheduled due to some other failure.
   529  	if j.URL == "" {
   530  		return fmt.Sprintf(jobReportFormatUrlNotFound, j.Icon, j.Name, strings.ToUpper(string(j.State)))
   531  	}
   532  
   533  	return fmt.Sprintf(jobReportFormat, j.Icon, j.Name, j.URL, strings.ToUpper(string(j.State)))
   534  }
   535  
   536  func deserialize(s string, j *Job) error {
   537  	var state string
   538  	var formats = []struct {
   539  		regex  string
   540  		tokens []*string
   541  	}{
   542  		// Legacy format. This is to cover the case where we're still trying to
   543  		// parse legacy style comments during the transition to the new style
   544  		// (just in case).
   545  		//
   546  		// TODO(listx): It should be safe to delete this legacy format checker
   547  		// after we migrate all Prow instances over to the version of crier's
   548  		// gerrit reporter (this file) that uses the Markdown-flavored links.
   549  		// There is no hurry to delete this code because having it here is
   550  		// harmless, other than incurring negligible CPU cycles.
   551  		{jobReportFormatLegacyRegex,
   552  			[]*string{&j.Icon, &j.Name, &state, &j.URL}},
   553  
   554  		// New format with Markdown syntax for the URL.
   555  		{jobReportFormatRegex,
   556  			[]*string{&j.Icon, &j.Name, &j.URL, &state}},
   557  
   558  		// New format, but where the URL was not found.
   559  		{jobReportFormatUrlNotFoundRegex,
   560  			[]*string{&j.Icon, &j.Name, &j.URL, &state}},
   561  
   562  		// Job without URL (because GenerateReport() decided that adding a URL would be too much).
   563  		{jobReportFormatWithoutURLRegex,
   564  			[]*string{&j.Icon, &j.Name, &state}},
   565  	}
   566  
   567  	for _, format := range formats {
   568  
   569  		re := regexp.MustCompile(format.regex)
   570  		if !re.MatchString(s) {
   571  			continue
   572  		}
   573  
   574  		// We drop the first token because it is the
   575  		// entire string itself.
   576  		matchedTokens := re.FindStringSubmatch(s)[1:]
   577  
   578  		// Even though the regexes are exact matches "^...$", we still check the
   579  		// number of tokens found just to be sure.
   580  		if len(matchedTokens) != len(format.tokens) {
   581  			return fmt.Errorf("tokens: got %d, want %d", len(format.tokens), len(matchedTokens))
   582  		}
   583  
   584  		for i := range format.tokens {
   585  			*format.tokens[i] = matchedTokens[i]
   586  		}
   587  
   588  		state = strings.ToLower(state)
   589  		validProwJobState := false
   590  		for _, pjState := range v1.GetAllProwJobStates() {
   591  			if v1.ProwJobState(state) == pjState {
   592  				validProwJobState = true
   593  				break
   594  			}
   595  		}
   596  		if !validProwJobState {
   597  			return fmt.Errorf("invalid prow job state %q", state)
   598  		}
   599  		j.State = v1.ProwJobState(state)
   600  
   601  		return nil
   602  	}
   603  
   604  	return fmt.Errorf("Could not deserialize %q to a job", s)
   605  }
   606  
   607  func headerMessageLine(success, total int, additionalText string) string {
   608  	return fmt.Sprintf(jobReportHeader, defaultProwHeader, success, total, additionalText)
   609  }
   610  
   611  func isHeaderMessageLine(s string) bool {
   612  	return strings.HasPrefix(s, defaultProwHeader)
   613  }
   614  
   615  func errorMessageLine(s string) string {
   616  	return fmt.Sprintf("[%s: %s]", errorLinePrefix, s)
   617  }
   618  
   619  func isErrorMessageLine(s string) bool {
   620  	return strings.HasPrefix(s, fmt.Sprintf("[%s: ", errorLinePrefix))
   621  }
   622  
   623  // GenerateReport generates a JobReport based on pjs passed in. As URLs are very
   624  // long string, including them in the report could easily make the report exceed
   625  // the maxCommentSizeLimit of 14400 characters.  Unfortunately we need info for
   626  // all prowjobs for /retest to work, which is by far the most reliable way of
   627  // retrieving prow jobs results (this is because prowjob custom resources are
   628  // garbage-collected by sinker after max_pod_age, which normally is 48 hours).
   629  // So to ensure that all prow jobs results are displayed, URLs for some of the
   630  // jobs are omitted from this report to keep it under 14400 characters.
   631  //
   632  // Note that even if we drop all URLs, it may be that we're forced to drop jobs
   633  // names entirely if there are just too many jobs. So there is actually no
   634  // guarantee that we'll always report all job names (although this is rare in
   635  // practice).
   636  //
   637  // customCommentSizeLimit is used by unit tests that actually test that we
   638  // perform job serialization with or without URLs (without this, our unit tests
   639  // would have to be very large to hit the default maxCommentSizeLimit to trigger
   640  // the "don't print URLs" behavior).
   641  func GenerateReport(pjs []*v1.ProwJob, customCommentSizeLimit int) JobReport {
   642  	// A JobReport has 2 string parts: (1) the "Header" that summarizes the
   643  	// report, and (2) a list of links to each job result (URL) (the "Message").
   644  	// We take care to make sure that the overall Header + Message falls under
   645  	// the commentSizeLimit, which is the maxCommentSizeLimit by default (this
   646  	// limit is parameterized so that we can test different size limits in unit
   647  	// tests).
   648  
   649  	// By default, use the maximum comment size limit const.
   650  	commentSizeLimit := maxCommentSizeLimit
   651  	if customCommentSizeLimit > 0 {
   652  		commentSizeLimit = customCommentSizeLimit
   653  	}
   654  
   655  	// Construct JobReport.
   656  	var additionalText string
   657  	report := JobReport{Total: len(pjs)}
   658  	for _, pj := range pjs {
   659  		job := jobFromPJ(pj)
   660  		report.Jobs = append(report.Jobs, job)
   661  		if pj.Status.State == v1.SuccessState {
   662  			report.Success++
   663  		}
   664  		if val, ok := pj.Labels[kube.CreatedByTideLabel]; ok && val == "true" {
   665  			additionalText = " (Not a duplicated report. Some of the jobs below were triggered by Tide)"
   666  		}
   667  	}
   668  	numJobs := len(report.Jobs)
   669  
   670  	report.prioritizeFailedJobs()
   671  
   672  	// Construct our comment that we want to send off to Gerrit. It is composed
   673  	// of the Header + Message.
   674  
   675  	// Construct report.Header portion.
   676  	report.Header = headerMessageLine(report.Success, report.Total, additionalText)
   677  	commentSize := len(report.Header)
   678  
   679  	// Construct report.Messages portion. We need to construct the long list of
   680  	// job result messages, delimited by a newline, where each message
   681  	// corresponds to a single job result.  These messages are concatenated
   682  	// together into report.Message.
   683  
   684  	// First just serialize without the URL. Afterwards, if we have room, we can
   685  	// start adding URLs as much as possible (failed jobs first). If we do not
   686  	// have room, simply truncate from the end of the list until we fall under
   687  	// the comment limit. This second scenario is highly unlikely, but is still
   688  	// something to consider (and tell the user about).
   689  	jobLines := []string{}
   690  	for _, job := range report.Jobs {
   691  		line := job.serializeWithoutURL()
   692  		jobLines = append(jobLines, line)
   693  		commentSize += len(line)
   694  	}
   695  
   696  	// Initially we skip displaying URLs for all jobs. Then depending on where
   697  	// we stand with our overall commentSize, we can try to either build it up
   698  	// (add URL links), or truncate it down (remove jobs from the end).
   699  	//
   700  	// For truncation, note that we truncate from the end, so that we prioritize
   701  	// reporting the names of the failed jobs (if any), which are at the front
   702  	// of the list.
   703  	skippedURLsFormat := "Skipped displaying URLs for %d/%d jobs due to reaching gerrit comment size limit"
   704  	errorLine := errorMessageLine(fmt.Sprintf(skippedURLsFormat, numJobs, numJobs))
   705  	commentSize += len(errorLine)
   706  	if commentSize < commentSizeLimit {
   707  		skipped := numJobs
   708  		for i, job := range report.Jobs {
   709  			lineWithURL := job.serialize()
   710  
   711  			lineSizeWithoutURL := len(jobLines[i])
   712  			lineSizeWithURL := len(lineWithURL)
   713  
   714  			delta := lineSizeWithURL - lineSizeWithoutURL
   715  
   716  			proposedErrorLine := errorMessageLine(fmt.Sprintf(skippedURLsFormat, skipped-1, numJobs))
   717  
   718  			// It could be that the new error line is smaller than the existing
   719  			// one, because e.g. `skipped` goes down from 100 to 99 (1 character
   720  			// less), or that we don't need the errorLine at all because there
   721  			// would be 0 skipped.
   722  			if skipped-1 == 0 {
   723  				proposedErrorLine = ""
   724  			}
   725  			delta -= (len(errorLine) - len(proposedErrorLine))
   726  
   727  			// Only replace the current line if the new commentSize would still
   728  			// be under the commentSizeLimit. Otherwise, break early because the
   729  			// commentSize is too big already.
   730  			if commentSize+delta < commentSizeLimit {
   731  				jobLines[i] = lineWithURL
   732  				commentSize += delta
   733  				errorLine = proposedErrorLine
   734  				skipped--
   735  			} else {
   736  				break
   737  			}
   738  		}
   739  
   740  		report.Message += strings.Join(jobLines, "")
   741  
   742  		if skipped > 0 {
   743  			report.Message += errorLine
   744  		}
   745  
   746  	} else {
   747  		// Drop existing errorLine (skip displaying URLs) because it no longer
   748  		// applies (we're skipping jobs entirely now, not just skipping the
   749  		// display of URLs).
   750  		commentSize -= len(errorLine)
   751  		errorLine = ""
   752  		skipped := 0
   753  		skippedJobsFormat := "Skipped displaying %d/%d jobs due to reaching gerrit comment size limit (too many jobs)"
   754  
   755  		last := numJobs - 1
   756  		for i := range report.Jobs {
   757  			j := last - i
   758  
   759  			// Truncate (delete) a job line.
   760  			commentSize -= len(jobLines[i])
   761  			jobLines[j] = ""
   762  			skipped++
   763  
   764  			// Construct new  errorLine to account for the truncation.
   765  			errorLine = errorMessageLine(fmt.Sprintf(skippedJobsFormat, skipped, numJobs))
   766  
   767  			// Break early if we've truncated enough to be under the
   768  			// commentSizeLimit.
   769  			if commentSize+len(errorLine) < commentSizeLimit {
   770  				break
   771  			}
   772  		}
   773  
   774  		report.Message += strings.Join(jobLines, "")
   775  		report.Message += errorLine
   776  	}
   777  
   778  	return report
   779  }
   780  
   781  // prioritizeFailedJobs sorts jobs so that the report will start with the failed
   782  // jobs first. This also makes it so that the failed jobs get priority in terms
   783  // of getting linked to the job URL.
   784  func (report *JobReport) prioritizeFailedJobs() {
   785  	sort.Slice(report.Jobs, func(i, j int) bool {
   786  		for _, state := range []v1.ProwJobState{
   787  			v1.FailureState,
   788  			v1.ErrorState,
   789  			v1.AbortedState,
   790  		} {
   791  			if report.Jobs[i].State == state {
   792  				return true
   793  			}
   794  			if report.Jobs[j].State == state {
   795  				return false
   796  			}
   797  		}
   798  		// We don't care about other states, so keep original order.
   799  		return true
   800  	})
   801  }
   802  
   803  // ParseReport creates a jobReport from a string, nil if cannot parse
   804  func ParseReport(message string) *JobReport {
   805  	contents := strings.Split(message, "\n")
   806  	start := 0
   807  	isReport := false
   808  	for start < len(contents) {
   809  		if isHeaderMessageLine(contents[start]) {
   810  			isReport = true
   811  			break
   812  		}
   813  		start++
   814  	}
   815  	if !isReport {
   816  		return nil
   817  	}
   818  	var report JobReport
   819  	report.Header = contents[start] + "\n"
   820  	for i := start + 1; i < len(contents); i++ {
   821  		if contents[i] == "" || isErrorMessageLine(contents[i]) {
   822  			continue
   823  		}
   824  		var j Job
   825  		if err := deserialize(contents[i], &j); err != nil {
   826  			logrus.Warn(err)
   827  			continue
   828  		}
   829  		report.Total++
   830  		if j.State == v1.SuccessState {
   831  			report.Success++
   832  		}
   833  		report.Jobs = append(report.Jobs, j)
   834  	}
   835  	report.Message = strings.TrimPrefix(message, report.Header+"\n")
   836  	return &report
   837  }
   838  
   839  // String implements Stringer for JobReport
   840  func (r JobReport) String() string {
   841  	return fmt.Sprintf("%s\n%s", r.Header, r.Message)
   842  }
   843  
   844  func lockKeyForPJ(pj *v1.ProwJob) (*criercommonlib.SimplePull, error) {
   845  	// TODO(chaodaiG): remove postsubmit once
   846  	// https://github.com/kubernetes/test-infra/issues/22653 is fixed
   847  	if pj.Spec.Type != v1.PresubmitJob && pj.Spec.Type != v1.PostsubmitJob {
   848  		return nil, fmt.Errorf("can only get lock key for presubmit and postsubmit jobs, was %q", pj.Spec.Type)
   849  	}
   850  	if pj.Spec.Refs == nil {
   851  		return nil, errors.New("pj.Spec.Refs is nil")
   852  	}
   853  	if n := len(pj.Spec.Refs.Pulls); n != 1 {
   854  		return nil, fmt.Errorf("prowjob doesn't have one but %d pulls", n)
   855  	}
   856  	return criercommonlib.NewSimplePull(pj.Spec.Refs.Org, pj.Spec.Refs.Repo, pj.Spec.Refs.Pulls[0].Number), nil
   857  }