github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/github/report/report.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 report contains helpers for writing comments and updating
    18  // statuses in GitHub.
    19  package report
    20  
    21  import (
    22  	"bytes"
    23  	"context"
    24  	"errors"
    25  	"fmt"
    26  	"strconv"
    27  	"strings"
    28  	"text/template"
    29  
    30  	prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    31  	"sigs.k8s.io/prow/pkg/config"
    32  	"sigs.k8s.io/prow/pkg/github"
    33  	"sigs.k8s.io/prow/pkg/kube"
    34  	"sigs.k8s.io/prow/pkg/plugins"
    35  )
    36  
    37  const (
    38  	commentTag = "<!-- test report -->"
    39  )
    40  
    41  // GitHubClient provides a client interface to report job status updates
    42  // through GitHub comments.
    43  type GitHubClient interface {
    44  	BotUserCheckerWithContext(ctx context.Context) (func(candidate string) bool, error)
    45  	CreateStatusWithContext(ctx context.Context, org, repo, ref string, s github.Status) error
    46  	ListIssueCommentsWithContext(ctx context.Context, org, repo string, number int) ([]github.IssueComment, error)
    47  	CreateCommentWithContext(ctx context.Context, org, repo string, number int, comment string) error
    48  	DeleteCommentWithContext(ctx context.Context, org, repo string, ID int) error
    49  	EditCommentWithContext(ctx context.Context, org, repo string, ID int, comment string) error
    50  }
    51  
    52  // prowjobStateToGitHubStatus maps prowjob status to github states.
    53  // GitHub states can be one of error, failure, pending, or success.
    54  // https://developer.github.com/v3/repos/statuses/#create-a-status
    55  func prowjobStateToGitHubStatus(pjState prowapi.ProwJobState) (string, error) {
    56  	switch pjState {
    57  	case prowapi.TriggeredState:
    58  		return github.StatusPending, nil
    59  	case prowapi.PendingState:
    60  		return github.StatusPending, nil
    61  	case prowapi.SuccessState:
    62  		return github.StatusSuccess, nil
    63  	case prowapi.ErrorState:
    64  		return github.StatusError, nil
    65  	case prowapi.FailureState:
    66  		return github.StatusFailure, nil
    67  	case prowapi.AbortedState:
    68  		return github.StatusFailure, nil
    69  	}
    70  	return "", fmt.Errorf("Unknown prowjob state: %s", pjState)
    71  }
    72  
    73  // reportStatus should be called on any prowjob status changes
    74  func reportStatus(ctx context.Context, ghc GitHubClient, pj prowapi.ProwJob) error {
    75  	refs := pj.Spec.Refs
    76  	if pj.Spec.Report {
    77  		contextState, err := prowjobStateToGitHubStatus(pj.Status.State)
    78  		if err != nil {
    79  			return err
    80  		}
    81  		sha := refs.BaseSHA
    82  		if len(refs.Pulls) > 0 {
    83  			sha = refs.Pulls[0].SHA
    84  		}
    85  		if err := ghc.CreateStatusWithContext(ctx, refs.Org, refs.Repo, sha, github.Status{
    86  			State:       contextState,
    87  			Description: config.ContextDescriptionWithBaseSha(pj.Status.Description, refs.BaseSHA),
    88  			Context:     pj.Spec.Context, // consider truncating this too
    89  			TargetURL:   pj.Status.URL,
    90  		}); err != nil {
    91  			return err
    92  		}
    93  	}
    94  	return nil
    95  }
    96  
    97  // TODO(krzyzacy):
    98  // Move this logic into github/reporter, once we unify all reporting logic to crier
    99  func ShouldReport(pj prowapi.ProwJob, validTypes []prowapi.ProwJobType) bool {
   100  	valid := false
   101  	for _, t := range validTypes {
   102  		if pj.Spec.Type == t {
   103  			valid = true
   104  		}
   105  	}
   106  
   107  	if !valid {
   108  		return false
   109  	}
   110  
   111  	if !pj.Spec.Report {
   112  		return false
   113  	}
   114  
   115  	return true
   116  }
   117  
   118  // Report is creating/updating/removing reports in GitHub based on the state of
   119  // the provided ProwJob.
   120  func Report(ctx context.Context, ghc GitHubClient, reportTemplate *template.Template, pj prowapi.ProwJob, config config.GitHubReporter) error {
   121  	if err := ReportStatusContext(ctx, ghc, pj, config); err != nil {
   122  		return err
   123  	}
   124  	return ReportComment(ctx, ghc, reportTemplate, []prowapi.ProwJob{pj}, config, false)
   125  }
   126  
   127  // ReportStatusContext reports prowjob status on a PR.
   128  func ReportStatusContext(ctx context.Context, ghc GitHubClient, pj prowapi.ProwJob, config config.GitHubReporter) error {
   129  	if ghc == nil {
   130  		return fmt.Errorf("trying to report pj %s, but found empty github client", pj.ObjectMeta.Name)
   131  	}
   132  
   133  	if !ShouldReport(pj, config.JobTypesToReport) {
   134  		return nil
   135  	}
   136  
   137  	refs := pj.Spec.Refs
   138  	// we are not reporting for batch jobs, we can consider support that in the future
   139  	if len(refs.Pulls) > 1 {
   140  		return nil
   141  	}
   142  
   143  	if err := reportStatus(ctx, ghc, pj); err != nil {
   144  		return fmt.Errorf("error setting status: %w", err)
   145  	}
   146  	return nil
   147  }
   148  
   149  // ReportComment takes multiple prowjobs as input. When there are more than one
   150  // prowjob, they are required to have identical refs, aka they are the same repo
   151  // and the same pull request.
   152  func ReportComment(ctx context.Context, ghc GitHubClient, reportTemplate *template.Template, pjs []prowapi.ProwJob, config config.GitHubReporter, mustCreate bool) error {
   153  	if ghc == nil {
   154  		return errors.New("trying to report pj, but found empty github client")
   155  	}
   156  
   157  	var validPjs []prowapi.ProwJob
   158  	for _, pj := range pjs {
   159  		// Report manually aborted Jenkins jobs and jobs with invalid pod specs alongside
   160  		// test successes/failures.
   161  		if ShouldReport(pj, config.JobTypesToReport) && pj.Complete() {
   162  			validPjs = append(validPjs, pj)
   163  		}
   164  	}
   165  	if len(validPjs) == 0 {
   166  		return nil
   167  	}
   168  
   169  	// Multiple prow jobs passed in to this function requires that all prowjobs from
   170  	// the input have exactly the same refs. Pick the ref from the first PR for checking
   171  	// whether to report or not.
   172  	refs := validPjs[0].Spec.Refs
   173  	// we are not reporting for batch jobs, we can consider support that in the future
   174  	if refs == nil || len(refs.Pulls) != 1 {
   175  		return nil
   176  	}
   177  
   178  	ics, err := ghc.ListIssueCommentsWithContext(ctx, refs.Org, refs.Repo, refs.Pulls[0].Number)
   179  	if err != nil {
   180  		return fmt.Errorf("error listing comments: %w", err)
   181  	}
   182  	botNameChecker, err := ghc.BotUserCheckerWithContext(ctx)
   183  	if err != nil {
   184  		return fmt.Errorf("error getting bot name checker: %w", err)
   185  	}
   186  	deletes, entries, updateID := parseIssueComments(validPjs, botNameChecker, ics)
   187  	for _, delete := range deletes {
   188  		if err := ghc.DeleteCommentWithContext(ctx, refs.Org, refs.Repo, delete); err != nil {
   189  			return fmt.Errorf("error deleting comment: %w", err)
   190  		}
   191  	}
   192  
   193  	// If there are any aborted pjs for this ref we don't want to report that all tests passed.
   194  	// This could be due to a push while pjs are running.
   195  	aborted := false
   196  	for _, pj := range validPjs {
   197  		if pj.Status.State == prowapi.AbortedState {
   198  			aborted = true
   199  			break
   200  		}
   201  	}
   202  
   203  	if len(entries) > 0 || (mustCreate && !aborted) {
   204  		comment, err := createComment(reportTemplate, validPjs, entries)
   205  		if err != nil {
   206  			return fmt.Errorf("generating comment: %w", err)
   207  		}
   208  		if updateID == 0 {
   209  			if err := ghc.CreateCommentWithContext(ctx, refs.Org, refs.Repo, refs.Pulls[0].Number, comment); err != nil {
   210  				return fmt.Errorf("error creating comment: %w", err)
   211  			}
   212  		} else {
   213  			if err := ghc.EditCommentWithContext(ctx, refs.Org, refs.Repo, updateID, comment); err != nil {
   214  				return fmt.Errorf("error updating comment: %w", err)
   215  			}
   216  		}
   217  	}
   218  	return nil
   219  }
   220  
   221  // parseIssueComments returns a list of comments to delete, a list of table
   222  // entries, and the ID of the comment to update. If there are no table entries
   223  // then don't make a new comment. Otherwise, if the comment to update is 0,
   224  // create a new comment.
   225  func parseIssueComments(pjs []prowapi.ProwJob, isBot func(string) bool, ics []github.IssueComment) ([]int, []string, int) {
   226  	var delete []int
   227  	var previousComments []int
   228  	var latestComment int
   229  	var entries []string
   230  	// First accumulate result entries and comment IDs
   231  	for _, ic := range ics {
   232  		if !isBot(ic.User.Login) {
   233  			continue
   234  		}
   235  		if !strings.Contains(ic.Body, commentTag) {
   236  			continue
   237  		}
   238  		if latestComment != 0 {
   239  			previousComments = append(previousComments, latestComment)
   240  		}
   241  		latestComment = ic.ID
   242  		var tracking bool
   243  		for _, line := range strings.Split(ic.Body, "\n") {
   244  			line = strings.TrimSpace(line)
   245  			if strings.HasPrefix(line, "---") {
   246  				tracking = true
   247  			} else if len(line) == 0 {
   248  				tracking = false
   249  			} else if tracking {
   250  				entries = append(entries, line)
   251  			}
   252  		}
   253  	}
   254  	var newEntries []string
   255  	// Next decide which entries to keep.
   256  	pjsMap := make(map[string]prowapi.ProwJob)
   257  	for _, pj := range pjs {
   258  		pjsMap[pj.Spec.Context] = pj
   259  	}
   260  	for i := range entries {
   261  		keep := true
   262  		f1 := strings.Split(entries[i], " | ")
   263  		for j := range entries {
   264  			if i == j {
   265  				continue
   266  			}
   267  			f2 := strings.Split(entries[j], " | ")
   268  			// Use the newer results if there are multiple.
   269  			if j > i && f2[0] == f1[0] {
   270  				keep = false
   271  			}
   272  		}
   273  		// Use the current result if there is an old one.
   274  		if _, ok := pjsMap[f1[0]]; ok {
   275  			keep = false
   276  		}
   277  		if keep {
   278  			newEntries = append(newEntries, entries[i])
   279  		}
   280  	}
   281  	var createNewComment bool
   282  	for _, pj := range pjs {
   283  		if string(pj.Status.State) == github.StatusFailure {
   284  			newEntries = append(newEntries, createEntry(pj))
   285  			createNewComment = true
   286  		}
   287  	}
   288  	delete = append(delete, previousComments...)
   289  	if (createNewComment || len(newEntries) == 0) && latestComment != 0 {
   290  		delete = append(delete, latestComment)
   291  		latestComment = 0
   292  	}
   293  	return delete, newEntries, latestComment
   294  }
   295  
   296  func createEntry(pj prowapi.ProwJob) string {
   297  	required := "unknown"
   298  
   299  	if pj.Spec.Type == prowapi.PresubmitJob {
   300  		if label, exist := pj.Labels[kube.IsOptionalLabel]; exist {
   301  			if optional, err := strconv.ParseBool(label); err == nil {
   302  				required = strconv.FormatBool(!optional)
   303  			}
   304  		}
   305  	}
   306  
   307  	return strings.Join([]string{
   308  		pj.Spec.Context,
   309  		pj.Spec.Refs.Pulls[0].SHA,
   310  		fmt.Sprintf("[link](%s)", pj.Status.URL),
   311  		required,
   312  		fmt.Sprintf("`%s`", pj.Spec.RerunCommand),
   313  	}, " | ")
   314  }
   315  
   316  // createComment take a ProwJob and a list of entries generated with
   317  // createEntry and returns a nicely formatted comment. It may fail if template
   318  // execution fails.
   319  func createComment(reportTemplate *template.Template, pjs []prowapi.ProwJob, entries []string) (string, error) {
   320  	if len(pjs) == 0 {
   321  		return "", nil
   322  	}
   323  	plural := ""
   324  	if len(entries) > 1 {
   325  		plural = "s"
   326  	}
   327  	var b bytes.Buffer
   328  	// The report template is usually related to the PR not a specific PJ,
   329  	// even though it is using the PJ in the template. This is kind of unfortunate
   330  	// and doesn't really make sense given that we maintain one failure comment
   331  	// on PRs, not one per PJ. So we might be better off using the first PJ
   332  	// and still executing the template even if there are multiple PJs.
   333  	if reportTemplate != nil {
   334  		if err := reportTemplate.Execute(&b, &pjs[0]); err != nil {
   335  			return "", err
   336  		}
   337  	}
   338  	lines := []string{
   339  		fmt.Sprintf("@%s: The following test%s **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:", pjs[0].Spec.Refs.Pulls[0].Author, plural),
   340  		"",
   341  		"Test name | Commit | Details | Required | Rerun command",
   342  		"--- | --- | --- | --- | ---",
   343  	}
   344  	if len(entries) == 0 { // No test failed
   345  		lines = []string{
   346  			fmt.Sprintf("@%s: all tests **passed!**", pjs[0].Spec.Refs.Pulls[0].Author),
   347  			"",
   348  		}
   349  	}
   350  	lines = append(lines, entries...)
   351  	if reportTemplate != nil {
   352  		lines = append(lines, "", b.String())
   353  	}
   354  	lines = append(lines, []string{
   355  		"",
   356  		"<details>",
   357  		"",
   358  		plugins.AboutThisBot,
   359  		"</details>",
   360  		commentTag,
   361  	}...)
   362  	return strings.Join(lines, "\n"), nil
   363  }