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 }