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 }