sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/bugzilla/bugzilla.go (about) 1 /* 2 Copyright 2019 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 bugzilla ensures that pull requests reference a Bugzilla bug in their title 18 package bugzilla 19 20 import ( 21 "context" 22 "encoding/json" 23 "fmt" 24 "regexp" 25 "sort" 26 "strconv" 27 "strings" 28 29 githubql "github.com/shurcooL/githubv4" 30 "github.com/sirupsen/logrus" 31 "k8s.io/apimachinery/pkg/util/sets" 32 33 "sigs.k8s.io/prow/pkg/bugzilla" 34 "sigs.k8s.io/prow/pkg/config" 35 "sigs.k8s.io/prow/pkg/github" 36 "sigs.k8s.io/prow/pkg/labels" 37 "sigs.k8s.io/prow/pkg/pluginhelp" 38 "sigs.k8s.io/prow/pkg/plugins" 39 ) 40 41 var ( 42 titleMatch = regexp.MustCompile(`(?i)Bug\s+([0-9]+):`) 43 refreshCommandMatch = regexp.MustCompile(`(?mi)^/bugzilla refresh\s*$`) 44 qaAssignCommandMatch = regexp.MustCompile(`(?mi)^/bugzilla assign-qa\s*$`) 45 qaReviewCommandMatch = regexp.MustCompile(`(?mi)^/bugzilla cc-qa\s*$`) 46 cherrypickPRMatch = regexp.MustCompile(`This is an automated cherry-pick of #([0-9]+)`) 47 ) 48 49 const ( 50 PluginName = "bugzilla" 51 bugLink = `[Bugzilla bug %d](%s/show_bug.cgi?id=%d)` 52 urgentSeverity = "urgent" 53 highSeverity = "high" 54 medSeverity = "medium" 55 lowSeverity = "low" 56 unspecifiedSeverity = "unspecified" 57 ) 58 59 func init() { 60 plugins.RegisterGenericCommentHandler(PluginName, handleGenericComment, helpProvider) 61 plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider) 62 } 63 64 func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 65 configInfo := make(map[string]string) 66 for _, repo := range enabledRepos { 67 opts := config.Bugzilla.OptionsForRepo(repo.Org, repo.Repo) 68 if len(opts) == 0 { 69 continue 70 } 71 // we need to make sure the order of this help is consistent for page reloads and testing 72 var branches []string 73 for branch := range opts { 74 branches = append(branches, branch) 75 } 76 sort.Strings(branches) 77 var configInfoStrings []string 78 configInfoStrings = append(configInfoStrings, "The plugin has the following configuration:<ul>") 79 for _, branch := range branches { 80 var message string 81 if branch == plugins.BugzillaOptionsWildcard { 82 message = "by default, " 83 } else { 84 message = fmt.Sprintf("on the %q branch, ", branch) 85 } 86 message += "valid bugs must " 87 var conditions []string 88 if opts[branch].IsOpen != nil { 89 if *opts[branch].IsOpen { 90 conditions = append(conditions, "be open") 91 } else { 92 conditions = append(conditions, "be closed") 93 } 94 } 95 if opts[branch].TargetRelease != nil { 96 conditions = append(conditions, fmt.Sprintf("target the %q release", *opts[branch].TargetRelease)) 97 } 98 if opts[branch].ValidStates != nil && len(*opts[branch].ValidStates) > 0 { 99 pretty := strings.Join(prettyStates(*opts[branch].ValidStates), ", ") 100 conditions = append(conditions, fmt.Sprintf("be in one of the following states: %s", pretty)) 101 } 102 if opts[branch].DependentBugStates != nil || opts[branch].DependentBugTargetReleases != nil { 103 conditions = append(conditions, "depend on at least one other bug") 104 } 105 if opts[branch].DependentBugStates != nil { 106 pretty := strings.Join(prettyStates(*opts[branch].DependentBugStates), ", ") 107 conditions = append(conditions, fmt.Sprintf("have all dependent bugs in one of the following states: %s", pretty)) 108 } 109 if opts[branch].DependentBugTargetReleases != nil { 110 conditions = append(conditions, fmt.Sprintf("have all dependent bugs in one of the following target releases: %s", strings.Join(*opts[branch].DependentBugTargetReleases, ", "))) 111 } 112 switch len(conditions) { 113 case 0: 114 message += "exist" 115 case 1: 116 message += conditions[0] 117 case 2: 118 message += fmt.Sprintf("%s and %s", conditions[0], conditions[1]) 119 default: 120 conditions[len(conditions)-1] = fmt.Sprintf("and %s", conditions[len(conditions)-1]) 121 message += strings.Join(conditions, ", ") 122 } 123 var updates []string 124 if opts[branch].StateAfterValidation != nil { 125 updates = append(updates, fmt.Sprintf("moved to the %s state", opts[branch].StateAfterValidation)) 126 } 127 if opts[branch].AddExternalLink != nil && *opts[branch].AddExternalLink { 128 updates = append(updates, "updated to refer to the pull request using the external bug tracker") 129 } 130 if opts[branch].StateAfterMerge != nil { 131 updates = append(updates, fmt.Sprintf("moved to the %s state when all linked pull requests are merged", opts[branch].StateAfterMerge)) 132 } 133 134 if len(updates) > 0 { 135 message += ". After being linked to a pull request, bugs will be " 136 } 137 switch len(updates) { 138 case 0: 139 case 1: 140 message += updates[0] 141 case 2: 142 message += fmt.Sprintf("%s and %s", updates[0], updates[1]) 143 default: 144 updates[len(updates)-1] = fmt.Sprintf("and %s", updates[len(updates)-1]) 145 message += strings.Join(updates, ", ") 146 } 147 configInfoStrings = append(configInfoStrings, "<li>"+message+".</li>") 148 } 149 configInfoStrings = append(configInfoStrings, "</ul>") 150 151 configInfo[repo.String()] = strings.Join(configInfoStrings, "\n") 152 } 153 str := func(s string) *string { return &s } 154 yes := true 155 no := false 156 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 157 Bugzilla: plugins.Bugzilla{ 158 Default: map[string]plugins.BugzillaBranchOptions{ 159 "*": { 160 ValidateByDefault: &yes, 161 IsOpen: &yes, 162 TargetRelease: str("release1"), 163 Statuses: &[]string{"NEW", "MODIFIED", "VERIFIED", "IN_PROGRESS", "CLOSED", "RELEASE_PENDING"}, 164 ValidStates: &[]plugins.BugzillaBugState{ 165 { 166 Status: "MODIFIED", 167 }, 168 { 169 Status: "CLOSED", 170 Resolution: "ERRATA", 171 }, 172 }, 173 DependentBugStatuses: &[]string{"NEW", "MODIFIED"}, 174 DependentBugStates: &[]plugins.BugzillaBugState{ 175 { 176 Status: "MODIFIED", 177 }, 178 }, 179 DependentBugTargetReleases: &[]string{"release1", "release2"}, 180 StatusAfterValidation: str("VERIFIED"), 181 StateAfterValidation: &plugins.BugzillaBugState{ 182 Status: "VERIFIED", 183 }, 184 AddExternalLink: &no, 185 StatusAfterMerge: str("RELEASE_PENDING"), 186 StateAfterMerge: &plugins.BugzillaBugState{ 187 Status: "RELEASE_PENDING", 188 Resolution: "RESOLVED", 189 }, 190 StateAfterClose: &plugins.BugzillaBugState{ 191 Status: "RESET", 192 Resolution: "FIXED", 193 }, 194 AllowedGroups: []string{"group1", "groups2"}, 195 }, 196 }, 197 Orgs: map[string]plugins.BugzillaOrgOptions{ 198 "org": { 199 Default: map[string]plugins.BugzillaBranchOptions{ 200 "*": { 201 ExcludeDefaults: &yes, 202 ValidateByDefault: &yes, 203 IsOpen: &yes, 204 TargetRelease: str("release1"), 205 Statuses: &[]string{"NEW", "MODIFIED", "VERIFIED", "IN_PROGRESS", "CLOSED", "RELEASE_PENDING"}, 206 ValidStates: &[]plugins.BugzillaBugState{ 207 { 208 Status: "MODIFIED", 209 }, 210 { 211 Status: "CLOSED", 212 Resolution: "ERRATA", 213 }, 214 }, 215 DependentBugStatuses: &[]string{"NEW", "MODIFIED"}, 216 DependentBugStates: &[]plugins.BugzillaBugState{ 217 { 218 Status: "MODIFIED", 219 }, 220 }, 221 DependentBugTargetReleases: &[]string{"release1", "release2"}, 222 StatusAfterValidation: str("VERIFIED"), 223 StateAfterValidation: &plugins.BugzillaBugState{ 224 Status: "VERIFIED", 225 }, 226 AddExternalLink: &no, 227 StatusAfterMerge: str("RELEASE_PENDING"), 228 StateAfterMerge: &plugins.BugzillaBugState{ 229 Status: "RELEASE_PENDING", 230 Resolution: "RESOLVED", 231 }, 232 StateAfterClose: &plugins.BugzillaBugState{ 233 Status: "RESET", 234 Resolution: "FIXED", 235 }, 236 AllowedGroups: []string{"group1", "groups2"}, 237 }, 238 }, 239 Repos: map[string]plugins.BugzillaRepoOptions{ 240 "repo": { 241 Branches: map[string]plugins.BugzillaBranchOptions{ 242 "branch": { 243 ExcludeDefaults: &no, 244 ValidateByDefault: &yes, 245 IsOpen: &yes, 246 TargetRelease: str("release1"), 247 Statuses: &[]string{"NEW", "MODIFIED", "VERIFIED", "IN_PROGRESS", "CLOSED", "RELEASE_PENDING"}, 248 ValidStates: &[]plugins.BugzillaBugState{ 249 { 250 Status: "MODIFIED", 251 }, 252 { 253 Status: "CLOSED", 254 Resolution: "ERRATA", 255 }, 256 }, 257 DependentBugStatuses: &[]string{"NEW", "MODIFIED"}, 258 DependentBugStates: &[]plugins.BugzillaBugState{ 259 { 260 Status: "MODIFIED", 261 }, 262 }, 263 DependentBugTargetReleases: &[]string{"release1", "release2"}, 264 StatusAfterValidation: str("VERIFIED"), 265 StateAfterValidation: &plugins.BugzillaBugState{ 266 Status: "VERIFIED", 267 }, 268 AddExternalLink: &no, 269 StatusAfterMerge: str("RELEASE_PENDING"), 270 StateAfterMerge: &plugins.BugzillaBugState{ 271 Status: "RELEASE_PENDING", 272 Resolution: "RESOLVED", 273 }, 274 StateAfterClose: &plugins.BugzillaBugState{ 275 Status: "RESET", 276 Resolution: "FIXED", 277 }, 278 AllowedGroups: []string{"group1", "groups2"}, 279 }, 280 }, 281 }, 282 }, 283 }, 284 }, 285 }, 286 }) 287 if err != nil { 288 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName) 289 } 290 pluginHelp := &pluginhelp.PluginHelp{ 291 Description: "The bugzilla plugin ensures that pull requests reference a valid Bugzilla bug in their title.", 292 Config: configInfo, 293 Snippet: yamlSnippet, 294 } 295 pluginHelp.AddCommand(pluginhelp.Command{ 296 Usage: "/bugzilla refresh", 297 Description: "Check Bugzilla for a valid bug referenced in the PR title", 298 Featured: false, 299 WhoCanUse: "Anyone", 300 Examples: []string{"/bugzilla refresh"}, 301 }) 302 pluginHelp.AddCommand(pluginhelp.Command{ 303 Usage: "/bugzilla assign-qa", 304 Description: "(DEPRECATED) Assign PR to QA contact specified in Bugzilla", 305 Featured: false, 306 WhoCanUse: "Anyone", 307 Examples: []string{"/bugzilla assign-qa"}, 308 }) 309 pluginHelp.AddCommand(pluginhelp.Command{ 310 Usage: "/bugzilla cc-qa", 311 Description: "Request PR review from QA contact specified in Bugzilla", 312 Featured: false, 313 WhoCanUse: "Anyone", 314 Examples: []string{"/bugzilla cc-qa"}, 315 }) 316 return pluginHelp, nil 317 } 318 319 type githubClient interface { 320 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 321 CreateComment(owner, repo string, number int, comment string) error 322 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 323 AddLabel(owner, repo string, number int, label string) error 324 RemoveLabel(owner, repo string, number int, label string) error 325 WasLabelAddedByHuman(org, repo string, num int, label string) (bool, error) 326 Query(ctx context.Context, q interface{}, vars map[string]interface{}) error 327 } 328 329 func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) (err error) { 330 defer func() { 331 if r := recover(); r != nil { 332 err = fmt.Errorf("recovered panic in bugzilla plugin: %v", r) 333 } 334 }() 335 event, err := digestComment(pc.GitHubClient, pc.Logger, e) 336 if err != nil { 337 return err 338 } 339 if event != nil { 340 options := pc.PluginConfig.Bugzilla.OptionsForBranch(event.org, event.repo, event.baseRef) 341 return handle(*event, pc.GitHubClient, pc.BugzillaClient, options, pc.Logger, pc.Config.AllRepos) 342 } 343 return nil 344 } 345 346 func handlePullRequest(pc plugins.Agent, pre github.PullRequestEvent) (err error) { 347 defer func() { 348 if r := recover(); r != nil { 349 err = fmt.Errorf("recovered panic in bugzilla plugin: %v", r) 350 } 351 }() 352 options := pc.PluginConfig.Bugzilla.OptionsForBranch(pre.PullRequest.Base.Repo.Owner.Login, pre.PullRequest.Base.Repo.Name, pre.PullRequest.Base.Ref) 353 event, err := digestPR(pc.Logger, pre, options.ValidateByDefault) 354 if err != nil { 355 return err 356 } 357 if event != nil { 358 return handle(*event, pc.GitHubClient, pc.BugzillaClient, options, pc.Logger, pc.Config.AllRepos) 359 } 360 return nil 361 } 362 363 func getCherryPickMatch(pre github.PullRequestEvent) (bool, int, string, error) { 364 cherrypickMatch := cherrypickPRMatch.FindStringSubmatch(pre.PullRequest.Body) 365 if cherrypickMatch != nil { 366 cherrypickOf, err := strconv.Atoi(cherrypickMatch[1]) 367 if err != nil { 368 // should be impossible based on the regex 369 return false, 0, "", fmt.Errorf("Failed to parse cherrypick bugID as int - is the regex correct? Err: %w", err) 370 } 371 return true, cherrypickOf, pre.PullRequest.Base.Ref, nil 372 } 373 return false, 0, "", nil 374 } 375 376 // digestPR determines if any action is necessary and creates the objects for handle() if it is 377 func digestPR(log *logrus.Entry, pre github.PullRequestEvent, validateByDefault *bool) (*event, error) { 378 // These are the only actions indicating the PR title may have changed or that the PR merged or was closed 379 if pre.Action != github.PullRequestActionOpened && 380 pre.Action != github.PullRequestActionReopened && 381 pre.Action != github.PullRequestActionEdited && 382 pre.Action != github.PullRequestActionClosed { 383 return nil, nil 384 } 385 386 var ( 387 org = pre.PullRequest.Base.Repo.Owner.Login 388 repo = pre.PullRequest.Base.Repo.Name 389 baseRef = pre.PullRequest.Base.Ref 390 number = pre.PullRequest.Number 391 title = pre.PullRequest.Title 392 ) 393 394 e := &event{org: org, repo: repo, baseRef: baseRef, number: number, merged: pre.PullRequest.Merged, closed: pre.Action == github.PullRequestActionClosed, opened: pre.Action == github.PullRequestActionOpened, state: pre.PullRequest.State, body: title, htmlUrl: pre.PullRequest.HTMLURL, login: pre.PullRequest.User.Login} 395 // Make sure the PR title is referencing a bug 396 var err error 397 e.bugId, e.missing, err = bugIDFromTitle(title) 398 // in the case that the title used to reference a bug and no longer does we 399 // want to handle this to remove labels 400 if err != nil { 401 log.WithError(err).Debug("Failed to get bug ID from title") 402 return nil, err 403 } 404 405 // Check if PR is a cherrypick 406 cherrypick, cherrypickFromPRNum, cherrypickTo, err := getCherryPickMatch(pre) 407 if err != nil { 408 log.WithError(err).Debug("Failed to identify if PR is a cherrypick") 409 return nil, err 410 } else if cherrypick { 411 if pre.Action == github.PullRequestActionOpened { 412 e.cherrypick = true 413 e.cherrypickFromPRNum = cherrypickFromPRNum 414 e.cherrypickTo = cherrypickTo 415 return e, nil 416 } 417 } 418 419 if e.closed && !e.merged { 420 // if the PR was closed, we do not need to check for any other 421 // conditions like cherry-picks or title edits and can just 422 // handle it 423 return e, nil 424 } 425 426 // when exiting early from errors trying to find out if the PR previously referenced a bug, 427 // we want to handle the event only if a bug is currently referenced or we are validating by 428 // default 429 var intermediate *event 430 if !e.missing || (validateByDefault != nil && *validateByDefault) { 431 intermediate = e 432 } 433 434 // Check if the previous version of the title referenced a bug. 435 var changes struct { 436 Title struct { 437 From string `json:"from"` 438 } `json:"title"` 439 } 440 if err := json.Unmarshal(pre.Changes, &changes); err != nil { 441 // we're detecting this best-effort so we can handle it anyway 442 return intermediate, nil 443 } 444 prevId, missing, err := bugIDFromTitle(changes.Title.From) 445 if missing { 446 // title did not previously reference a bug 447 return intermediate, nil 448 } else if err != nil { 449 // should be impossible based on the regex, ignore err as this is best-effort 450 log.WithError(err).Debug("Failed get previous bug ID") 451 return intermediate, nil 452 } 453 454 // if the referenced bug has not changed in the update, ignore it 455 if prevId == e.bugId { 456 logrus.Debugf("Referenced Bugzilla ID (%d) has not changed, not handling event.", e.bugId) 457 return nil, nil 458 } 459 460 // we know the PR previously referenced a bug, so whether 461 // it currently does or does not reference a bug, we should 462 // handle the event 463 return e, nil 464 } 465 466 // digestComment determines if any action is necessary and creates the objects for handle() if it is 467 func digestComment(gc githubClient, log *logrus.Entry, gce github.GenericCommentEvent) (*event, error) { 468 // Only consider new comments. 469 if gce.Action != github.GenericCommentActionCreated { 470 return nil, nil 471 } 472 // Make sure they are requesting a valid command 473 var assign, cc bool 474 switch { 475 case refreshCommandMatch.MatchString(gce.Body): 476 // continue without updating bool values 477 case qaAssignCommandMatch.MatchString(gce.Body): 478 assign = true 479 case qaReviewCommandMatch.MatchString(gce.Body): 480 cc = true 481 default: 482 return nil, nil 483 } 484 var ( 485 org = gce.Repo.Owner.Login 486 repo = gce.Repo.Name 487 number = gce.Number 488 ) 489 490 // We don't support linking issues to Bugs 491 if !gce.IsPR { 492 log.Debug("Bugzilla command requested on an issue, ignoring") 493 return nil, gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(gce.Body, gce.HTMLURL, gce.User.Login, `Bugzilla bug referencing is only supported for Pull Requests, not issues.`)) 494 } 495 496 // Make sure the PR title is referencing a bug 497 pr, err := gc.GetPullRequest(org, repo, number) 498 if err != nil { 499 return nil, err 500 } 501 502 e := &event{org: org, repo: repo, baseRef: pr.Base.Ref, number: number, merged: pr.Merged, state: pr.State, body: gce.Body, htmlUrl: gce.HTMLURL, login: gce.User.Login, assign: assign, cc: cc} 503 e.bugId, e.missing, err = bugIDFromTitle(pr.Title) 504 if err != nil { 505 // should be impossible based on the regex 506 log.WithError(err).Debug("Failed to get bug ID from PR title") 507 return nil, err 508 } 509 510 return e, nil 511 } 512 513 type event struct { 514 org, repo, baseRef string 515 number, bugId int 516 missing, merged, closed, opened bool 517 state string 518 body, htmlUrl, login string 519 assign, cc bool 520 cherrypick bool 521 cherrypickFromPRNum int 522 cherrypickTo string 523 } 524 525 func (e *event) comment(gc githubClient) func(body string) error { 526 return func(body string) error { 527 return gc.CreateComment(e.org, e.repo, e.number, plugins.FormatResponseRaw(e.body, e.htmlUrl, e.login, body)) 528 } 529 } 530 531 type queryUser struct { 532 Login githubql.String 533 } 534 535 type queryNode struct { 536 User queryUser `graphql:"... on User"` 537 } 538 539 type queryEdge struct { 540 Node queryNode 541 } 542 543 type querySearch struct { 544 Edges []queryEdge 545 } 546 547 /* 548 emailToLoginQuery is a graphql query struct that should result in this graphql query: 549 550 { 551 search(type: USER, query: "email", first: 5) { 552 edges { 553 node { 554 ... on User { 555 login 556 } 557 } 558 } 559 } 560 } 561 */ 562 type emailToLoginQuery struct { 563 Search querySearch `graphql:"search(type:USER query:$email first:5)"` 564 } 565 566 // processQueryResult generates a response based on a populated emailToLoginQuery 567 func processQuery(query *emailToLoginQuery, email string, log *logrus.Entry) string { 568 switch len(query.Search.Edges) { 569 case 0: 570 return fmt.Sprintf("No GitHub users were found matching the public email listed for the QA contact in Bugzilla (%s), skipping review request.", email) 571 case 1: 572 return fmt.Sprintf("Requesting review from QA contact:\n/cc @%s", query.Search.Edges[0].Node.User.Login) 573 default: 574 response := fmt.Sprintf("Multiple GitHub users were found matching the public email listed for the QA contact in Bugzilla (%s), skipping review request. List of users with matching email:", email) 575 for _, edge := range query.Search.Edges { 576 response += fmt.Sprintf("\n\t- %s", edge.Node.User.Login) 577 } 578 return response 579 } 580 } 581 582 func handle(e event, gc githubClient, bc bugzilla.Client, options plugins.BugzillaBranchOptions, log *logrus.Entry, allRepos sets.Set[string]) error { 583 comment := e.comment(gc) 584 // check if bug is part of a restricted group 585 if !e.missing { 586 bug, err := getBug(bc, e.bugId, log, comment) 587 if err != nil || bug == nil { 588 return err 589 } 590 if !isBugAllowed(bug, options.AllowedGroups) { 591 // ignore bugs that are in non-allowed groups for this repo 592 if e.opened || refreshCommandMatch.MatchString(e.body) { 593 response := fmt.Sprintf(bugLink+" is in a bug group that is not in the allowed groups for this repo.", e.bugId, bc.Endpoint(), e.bugId) 594 if len(options.AllowedGroups) > 0 { 595 response += "\nAllowed groups for this repo are:" 596 for _, group := range options.AllowedGroups { 597 response += "\n- " + group 598 } 599 } else { 600 response += " There are no allowed bug groups configured for this repo." 601 } 602 return comment(response) 603 } 604 return nil 605 } 606 } 607 // merges follow a different pattern from the normal validation 608 if e.merged { 609 return handleMerge(e, gc, bc, options, log, allRepos) 610 } 611 // close events follow a different pattern from the normal validation 612 if e.closed && !e.merged { 613 return handleClose(e, gc, bc, options, log) 614 } 615 // cherrypicks follow a different pattern than normal validation 616 if e.cherrypick { 617 if *options.EnableBackporting { 618 return handleCherrypick(e, gc, bc, options, log) 619 } else { 620 return nil 621 } 622 } 623 624 var needsValidLabel, needsInvalidLabel bool 625 var response, severityLabel string 626 if e.missing { 627 log.WithField("bugMissing", true) 628 log.Debug("No bug referenced.") 629 needsValidLabel, needsInvalidLabel = false, false 630 response = `No Bugzilla bug is referenced in the title of this pull request. 631 To reference a bug, add 'Bug XXX:' to the title of this pull request and request another bug refresh with <code>/bugzilla refresh</code>.` 632 } else { 633 log = log.WithField("bugId", e.bugId) 634 635 bug, err := getBug(bc, e.bugId, log, comment) 636 if err != nil || bug == nil { 637 return err 638 } 639 severityLabel = getSeverityLabel(bug.Severity) 640 641 var dependents []bugzilla.Bug 642 if options.DependentBugStates != nil || options.DependentBugTargetReleases != nil { 643 for _, id := range bug.DependsOn { 644 dependent, err := bc.GetBug(id) 645 if err != nil { 646 return comment(formatError(fmt.Sprintf("searching for dependent bug %d", id), bc.Endpoint(), e.bugId, err)) 647 } 648 dependents = append(dependents, *dependent) 649 } 650 } 651 652 valid, validationsRun, why := validateBug(*bug, dependents, options, bc.Endpoint()) 653 needsValidLabel, needsInvalidLabel = valid, !valid 654 if valid { 655 log.Debug("Valid bug found.") 656 response = fmt.Sprintf(`This pull request references `+bugLink+`, which is valid.`, e.bugId, bc.Endpoint(), e.bugId) 657 // if configured, move the bug to the new state 658 if update := options.StateAfterValidation.AsBugUpdate(bug); update != nil { 659 if err := bc.UpdateBug(e.bugId, *update); err != nil { 660 log.WithError(err).Warn("Unexpected error updating Bugzilla bug.") 661 return comment(formatError(fmt.Sprintf("updating to the %s state", options.StateAfterValidation), bc.Endpoint(), e.bugId, err)) 662 } 663 response += fmt.Sprintf(" The bug has been moved to the %s state.", options.StateAfterValidation) 664 } 665 if options.AddExternalLink != nil && *options.AddExternalLink { 666 changed, err := bc.AddPullRequestAsExternalBug(e.bugId, e.org, e.repo, e.number) 667 if err != nil { 668 log.WithError(err).Warn("Unexpected error adding external tracker bug to Bugzilla bug.") 669 return comment(formatError("adding this pull request to the external tracker bugs", bc.Endpoint(), e.bugId, err)) 670 } 671 if changed { 672 response += " The bug has been updated to refer to the pull request using the external bug tracker." 673 } 674 } 675 676 response += "\n\n<details>" 677 if len(validationsRun) == 0 { 678 response += "<summary>No validations were run on this bug</summary>" 679 } else { 680 response += fmt.Sprintf("<summary>%d validation(s) were run on this bug</summary>\n", len(validationsRun)) 681 } 682 for _, validation := range validationsRun { 683 response += fmt.Sprint("\n* ", validation) 684 } 685 response += "</details>" 686 687 // identify qa contact via email if possible 688 explicitQARequest := e.assign || e.cc 689 if bug.QAContactDetail == nil { 690 if explicitQARequest { 691 response += fmt.Sprintf(bugLink+" does not have a QA contact, skipping assignment", e.bugId, bc.Endpoint(), e.bugId) 692 } 693 } else if bug.QAContactDetail.Email == "" { 694 if explicitQARequest { 695 response += fmt.Sprintf("QA contact for "+bugLink+" does not have a listed email, skipping assignment", e.bugId, bc.Endpoint(), e.bugId) 696 } 697 } else { 698 query := &emailToLoginQuery{} 699 email := bug.QAContactDetail.Email 700 queryVars := map[string]interface{}{ 701 "email": githubql.String(email), 702 } 703 err := gc.Query(context.Background(), query, queryVars) 704 if err != nil { 705 log.WithError(err).Error("Failed to run graphql github query") 706 return comment(formatError(fmt.Sprintf("querying GitHub for users with public email (%s)", email), bc.Endpoint(), e.bugId, err)) 707 } 708 response += fmt.Sprint("\n\n", processQuery(query, email, log)) 709 if e.assign { 710 response += "\n\n**DEPRECATION NOTICE**: The command `assign-qa` has been deprecated. Please use the `cc-qa` command instead." 711 } 712 } 713 } else { 714 log.Debug("Invalid bug found.") 715 var formattedReasons string 716 for _, reason := range why { 717 formattedReasons += fmt.Sprintf(" - %s\n", reason) 718 } 719 response = fmt.Sprintf(`This pull request references `+bugLink+`, which is invalid: 720 %s 721 Comment <code>/bugzilla refresh</code> to re-evaluate validity if changes to the Bugzilla bug are made, or edit the title of this pull request to link to a different bug.`, e.bugId, bc.Endpoint(), e.bugId, formattedReasons) 722 } 723 } 724 725 // ensure label state is correct. Do not propagate errors 726 // as it is more important to report to the user than to 727 // fail early on a label check. 728 currentLabels, err := gc.GetIssueLabels(e.org, e.repo, e.number) 729 if err != nil { 730 log.WithError(err).Warn("Could not list labels on PR") 731 } 732 var hasValidLabel, hasInvalidLabel bool 733 var severityLabelToRemove string 734 for _, l := range currentLabels { 735 if l.Name == labels.ValidBug { 736 hasValidLabel = true 737 } 738 if l.Name == labels.InvalidBug { 739 hasInvalidLabel = true 740 } 741 if l.Name == labels.BugzillaSeverityHigh || 742 l.Name == labels.BugzillaSeverityUrgent || 743 l.Name == labels.BugzillaSeverityMed || 744 l.Name == labels.BugzillaSeverityLow || 745 l.Name == labels.BugzillaSeverityUnspecified { 746 severityLabelToRemove = l.Name 747 } 748 } 749 750 if severityLabelToRemove != "" && severityLabel != severityLabelToRemove { 751 if err := gc.RemoveLabel(e.org, e.repo, e.number, severityLabelToRemove); err != nil { 752 log.WithError(err).Error("Failed to remove severity bug label.") 753 } 754 } 755 if severityLabel != "" && severityLabel != severityLabelToRemove { 756 if err := gc.AddLabel(e.org, e.repo, e.number, severityLabel); err != nil { 757 log.WithError(err).Error("Failed to add severity bug label.") 758 } 759 } 760 761 if hasValidLabel && !needsValidLabel { 762 humanLabelled, err := gc.WasLabelAddedByHuman(e.org, e.repo, e.number, labels.ValidBug) 763 if err != nil { 764 // Return rather than potentially doing the wrong thing. The user can re-trigger us. 765 return fmt.Errorf("failed to check if %s label was added by a human: %w", labels.ValidBug, err) 766 } 767 if humanLabelled { 768 // This will make us remove the invalid label if it exists but saves us another check if it was 769 // added by a human. It is reasonable to assume that it should be absent if the valid label was 770 // manually added. 771 needsInvalidLabel = false 772 needsValidLabel = true 773 response += fmt.Sprintf("\n\nRetaining the %s label as it was manually added.", labels.ValidBug) 774 } 775 } 776 777 if needsValidLabel && !hasValidLabel { 778 if err := gc.AddLabel(e.org, e.repo, e.number, labels.ValidBug); err != nil { 779 log.WithError(err).Error("Failed to add valid bug label.") 780 } 781 } else if !needsValidLabel && hasValidLabel { 782 if err := gc.RemoveLabel(e.org, e.repo, e.number, labels.ValidBug); err != nil { 783 log.WithError(err).Error("Failed to remove valid bug label.") 784 } 785 } 786 787 if needsInvalidLabel && !hasInvalidLabel { 788 if err := gc.AddLabel(e.org, e.repo, e.number, labels.InvalidBug); err != nil { 789 log.WithError(err).Error("Failed to add invalid bug label.") 790 } 791 } else if !needsInvalidLabel && hasInvalidLabel { 792 if err := gc.RemoveLabel(e.org, e.repo, e.number, labels.InvalidBug); err != nil { 793 log.WithError(err).Error("Failed to remove invalid bug label.") 794 } 795 } 796 797 return comment(response) 798 } 799 800 func getSeverityLabel(severity string) string { 801 switch severity { 802 case urgentSeverity: 803 return labels.BugzillaSeverityUrgent 804 case highSeverity: 805 return labels.BugzillaSeverityHigh 806 case medSeverity: 807 return labels.BugzillaSeverityMed 808 case lowSeverity: 809 return labels.BugzillaSeverityLow 810 case unspecifiedSeverity: 811 return labels.BugzillaSeverityUnspecified 812 } 813 //If we don't understand the severity, don't set it but don't error. 814 return "" 815 } 816 817 func bugMatchesStates(bug *bugzilla.Bug, states []plugins.BugzillaBugState) bool { 818 for _, state := range states { 819 if (&state).Matches(bug) { 820 return true 821 } 822 } 823 return false 824 } 825 826 func prettyStates(statuses []plugins.BugzillaBugState) []string { 827 pretty := make([]string, 0, len(statuses)) 828 for _, status := range statuses { 829 pretty = append(pretty, bugzilla.PrettyStatus(status.Status, status.Resolution)) 830 } 831 return pretty 832 } 833 834 // validateBug determines if the bug matches the options and returns a description of why not 835 func validateBug(bug bugzilla.Bug, dependents []bugzilla.Bug, options plugins.BugzillaBranchOptions, endpoint string) (bool, []string, []string) { 836 valid := true 837 var errors []string 838 var validations []string 839 if options.IsOpen != nil && *options.IsOpen != bug.IsOpen { 840 valid = false 841 not := "" 842 was := "isn't" 843 if !*options.IsOpen { 844 not = "not " 845 was = "is" 846 } 847 errors = append(errors, fmt.Sprintf("expected the bug to %sbe open, but it %s", not, was)) 848 } else if options.IsOpen != nil { 849 expected := "open" 850 if !*options.IsOpen { 851 expected = "not open" 852 } 853 was := "isn't" 854 if bug.IsOpen { 855 was = "is" 856 } 857 validations = append(validations, fmt.Sprintf("bug %s open, matching expected state (%s)", was, expected)) 858 } 859 860 if options.TargetRelease != nil { 861 if len(bug.TargetRelease) == 0 { 862 valid = false 863 errors = append(errors, fmt.Sprintf("expected the bug to target the %q release, but no target release was set", *options.TargetRelease)) 864 } else if *options.TargetRelease != bug.TargetRelease[0] { 865 // the BugZilla web UI shows one option for target release, but returns the 866 // field as a list in the REST API. We only care for the first item and it's 867 // not even clear if the list can have more than one item in the response 868 valid = false 869 errors = append(errors, fmt.Sprintf("expected the bug to target the %q release, but it targets %q instead", *options.TargetRelease, bug.TargetRelease[0])) 870 } else { 871 validations = append(validations, fmt.Sprintf("bug target release (%s) matches configured target release for branch (%s)", bug.TargetRelease[0], *options.TargetRelease)) 872 } 873 } 874 875 if options.ValidStates != nil { 876 var allowed []plugins.BugzillaBugState 877 allowed = append(allowed, *options.ValidStates...) 878 if options.StateAfterValidation != nil { 879 allowed = append(allowed, *options.StateAfterValidation) 880 } 881 if !bugMatchesStates(&bug, allowed) { 882 valid = false 883 errors = append(errors, fmt.Sprintf("expected the bug to be in one of the following states: %s, but it is %s instead", strings.Join(prettyStates(allowed), ", "), bugzilla.PrettyStatus(bug.Status, bug.Resolution))) 884 } else { 885 validations = append(validations, fmt.Sprintf("bug is in the state %s, which is one of the valid states (%s)", bugzilla.PrettyStatus(bug.Status, bug.Resolution), strings.Join(prettyStates(allowed), ", "))) 886 } 887 } 888 889 if options.DependentBugStates != nil { 890 for _, bug := range dependents { 891 if !bugMatchesStates(&bug, *options.DependentBugStates) { 892 valid = false 893 expected := strings.Join(prettyStates(*options.DependentBugStates), ", ") 894 actual := bugzilla.PrettyStatus(bug.Status, bug.Resolution) 895 errors = append(errors, fmt.Sprintf("expected dependent "+bugLink+" to be in one of the following states: %s, but it is %s instead", bug.ID, endpoint, bug.ID, expected, actual)) 896 } else { 897 validations = append(validations, fmt.Sprintf("dependent bug "+bugLink+" is in the state %s, which is one of the valid states (%s)", bug.ID, endpoint, bug.ID, bugzilla.PrettyStatus(bug.Status, bug.Resolution), strings.Join(prettyStates(*options.DependentBugStates), ", "))) 898 } 899 } 900 } 901 902 if options.DependentBugTargetReleases != nil { 903 for _, bug := range dependents { 904 if len(bug.TargetRelease) == 0 { 905 valid = false 906 errors = append(errors, fmt.Sprintf("expected dependent "+bugLink+" to target a release in %s, but no target release was set", bug.ID, endpoint, bug.ID, strings.Join(*options.DependentBugTargetReleases, ", "))) 907 } else { 908 // the BugZilla web UI shows one option for target release, but returns the 909 // field as a list in the REST API. We only care for the first item and it's 910 // not even clear if the list can have more than one item in the response 911 if sets.New[string](*options.DependentBugTargetReleases...).Has(bug.TargetRelease[0]) { 912 validations = append(validations, fmt.Sprintf("dependent "+bugLink+" targets the %q release, which is one of the valid target releases: %s", bug.ID, endpoint, bug.ID, bug.TargetRelease[0], strings.Join(*options.DependentBugTargetReleases, ", "))) 913 } else { 914 valid = false 915 errors = append(errors, fmt.Sprintf("expected dependent "+bugLink+" to target a release in %s, but it targets %q instead", bug.ID, endpoint, bug.ID, strings.Join(*options.DependentBugTargetReleases, ", "), bug.TargetRelease[0])) 916 } 917 } 918 } 919 } 920 921 if len(dependents) == 0 { 922 switch { 923 case options.DependentBugStates != nil && options.DependentBugTargetReleases != nil: 924 valid = false 925 expected := strings.Join(prettyStates(*options.DependentBugStates), ", ") 926 errors = append(errors, fmt.Sprintf("expected "+bugLink+" to depend on a bug targeting a release in %s and in one of the following states: %s, but no dependents were found", bug.ID, endpoint, bug.ID, strings.Join(*options.DependentBugTargetReleases, ", "), expected)) 927 case options.DependentBugStates != nil: 928 valid = false 929 expected := strings.Join(prettyStates(*options.DependentBugStates), ", ") 930 errors = append(errors, fmt.Sprintf("expected "+bugLink+" to depend on a bug in one of the following states: %s, but no dependents were found", bug.ID, endpoint, bug.ID, expected)) 931 case options.DependentBugTargetReleases != nil: 932 valid = false 933 errors = append(errors, fmt.Sprintf("expected "+bugLink+" to depend on a bug targeting a release in %s, but no dependents were found", bug.ID, endpoint, bug.ID, strings.Join(*options.DependentBugTargetReleases, ", "))) 934 default: 935 } 936 } else { 937 validations = append(validations, "bug has dependents") 938 } 939 940 return valid, validations, errors 941 } 942 943 func handleMerge(e event, gc githubClient, bc bugzilla.Client, options plugins.BugzillaBranchOptions, log *logrus.Entry, allRepos sets.Set[string]) error { 944 comment := e.comment(gc) 945 946 if options.StateAfterMerge == nil { 947 return nil 948 } 949 if e.missing { 950 return nil 951 } 952 if options.ValidStates != nil || options.StateAfterValidation != nil { 953 // we should only migrate if we can be fairly certain that the bug 954 // is not in a state that required human intervention to get to. 955 // For instance, if a bug is closed after a PR merges it should not 956 // be possible for /bugzilla refresh to move it back to the post-merge 957 // state. 958 bug, err := getBug(bc, e.bugId, log, comment) 959 if err != nil || bug == nil { 960 return err 961 } 962 var allowed []plugins.BugzillaBugState 963 if options.ValidStates != nil { 964 allowed = append(allowed, *options.ValidStates...) 965 } 966 967 if options.StateAfterValidation != nil { 968 allowed = append(allowed, *options.StateAfterValidation) 969 } 970 if !bugMatchesStates(bug, allowed) { 971 return comment(fmt.Sprintf(bugLink+" is in an unrecognized state (%s) and will not be moved to the %s state.", e.bugId, bc.Endpoint(), e.bugId, bugzilla.PrettyStatus(bug.Status, bug.Resolution), options.StateAfterMerge)) 972 } 973 } 974 975 prs, err := bc.GetExternalBugPRsOnBug(e.bugId) 976 if err != nil { 977 log.WithError(err).Warn("Unexpected error listing external tracker bugs for Bugzilla bug.") 978 return comment(formatError("searching for external tracker bugs", bc.Endpoint(), e.bugId, err)) 979 } 980 shouldMigrate := true 981 var mergedPRs []bugzilla.ExternalBug 982 unmergedPrStates := map[bugzilla.ExternalBug]string{} 983 for _, item := range prs { 984 var merged bool 985 var state string 986 if e.org == item.Org && e.repo == item.Repo && e.number == item.Num { 987 merged = e.merged 988 state = e.state 989 } else { 990 // This could be literally anything, only process PRs in repos that are mentioned in our config, otherwise this will potentially 991 // fail. 992 if !allRepos.Has(item.Org + "/" + item.Repo) { 993 logrus.WithField("pr", item.Org+"/"+item.Repo+"#"+strconv.Itoa(item.Num)).Debug("Not processing PR from third-party repo") 994 continue 995 } 996 pr, err := gc.GetPullRequest(item.Org, item.Repo, item.Num) 997 if err != nil { 998 log.WithError(err).Warn("Unexpected error checking merge state of related pull request.") 999 return comment(formatError(fmt.Sprintf("checking the state of a related pull request at https://github.com/%s/%s/pull/%d", item.Org, item.Repo, item.Num), bc.Endpoint(), e.bugId, err)) 1000 } 1001 merged = pr.Merged 1002 state = pr.State 1003 } 1004 if merged { 1005 mergedPRs = append(mergedPRs, item) 1006 } else { 1007 unmergedPrStates[item] = state 1008 } 1009 // only update Bugzilla bug status if all PRs have merged 1010 shouldMigrate = shouldMigrate && merged 1011 if !shouldMigrate { 1012 // we could give more complete feedback to the user by checking all PRs 1013 // but we save tokens by exiting when we find an unmerged one, so we 1014 // prefer to do that 1015 break 1016 } 1017 } 1018 1019 link := func(bug bugzilla.ExternalBug) string { 1020 return fmt.Sprintf("[%s/%s#%d](https://github.com/%s/%s/pull/%d)", bug.Org, bug.Repo, bug.Num, bug.Org, bug.Repo, bug.Num) 1021 } 1022 1023 mergedMessage := func(statement string) string { 1024 var links []string 1025 for _, bug := range mergedPRs { 1026 links = append(links, fmt.Sprintf(" * %s", link(bug))) 1027 } 1028 return fmt.Sprintf(`%s pull requests linked via external trackers have merged: 1029 %s 1030 1031 `, statement, strings.Join(links, "\n")) 1032 } 1033 1034 var statements []string 1035 for bug, state := range unmergedPrStates { 1036 statements = append(statements, fmt.Sprintf(" * %s is %s", link(bug), state)) 1037 } 1038 unmergedMessage := fmt.Sprintf(`The following pull requests linked via external trackers have not merged: 1039 %s 1040 1041 These pull request must merge or be unlinked from the Bugzilla bug in order for it to move to the next state. Once unlinked, request a bug refresh with <code>/bugzilla refresh</code>. 1042 1043 `, strings.Join(statements, "\n")) 1044 1045 outcomeMessage := func(action string) string { 1046 return fmt.Sprintf(bugLink+" has %sbeen moved to the %s state.", e.bugId, bc.Endpoint(), e.bugId, action, options.StateAfterMerge) 1047 } 1048 1049 update := options.StateAfterMerge.AsBugUpdate(nil) 1050 if update == nil { 1051 // should never happen 1052 return nil 1053 } 1054 1055 if shouldMigrate { 1056 if err := bc.UpdateBug(e.bugId, *update); err != nil { 1057 log.WithError(err).Warn("Unexpected error updating Bugzilla bug.") 1058 return comment(formatError(fmt.Sprintf("updating to the %s state", options.StateAfterMerge), bc.Endpoint(), e.bugId, err)) 1059 } 1060 return comment(fmt.Sprintf("%s%s", mergedMessage("All"), outcomeMessage(""))) 1061 } 1062 return comment(fmt.Sprintf("%s%s%s", mergedMessage("Some"), unmergedMessage, outcomeMessage("not "))) 1063 } 1064 1065 func handleCherrypick(e event, gc githubClient, bc bugzilla.Client, options plugins.BugzillaBranchOptions, log *logrus.Entry) error { 1066 comment := e.comment(gc) 1067 // get the info for the PR being cherrypicked from 1068 pr, err := gc.GetPullRequest(e.org, e.repo, e.cherrypickFromPRNum) 1069 if err != nil { 1070 log.WithError(err).Warn("Unexpected error getting title of pull request being cherrypicked from.") 1071 return comment(fmt.Sprintf("Error creating a cherry-pick bug in Bugzilla: failed to check the state of cherrypicked pull request at https://github.com/%s/%s/pull/%d: %v.\nPlease contact an administrator to resolve this issue, then request a bug refresh with <code>/bugzilla refresh</code>.", e.org, e.repo, e.cherrypickFromPRNum, err)) 1072 } 1073 // Attempt to identify bug from PR title 1074 bugID, bugMissing, err := bugIDFromTitle(pr.Title) 1075 if err != nil { 1076 // should be impossible based on the regex 1077 log.WithError(err).Debugf("Failed to get bug ID from PR title \"%s\"", pr.Title) 1078 return comment(fmt.Sprintf("Error creating a cherry-pick bug in Bugzilla: could not get bug ID from PR title \"%s\": %v", pr.Title, err)) 1079 } else if bugMissing { 1080 log.Debugf("Parent PR %d doesn't have associated bug; not creating cherrypicked bug", pr.Number) 1081 // if there is no bugzilla bug, we should simply ignore this PR 1082 return nil 1083 } 1084 // Since getBug generates a comment itself, we have to add a prefix explaining that this was a cherrypick attempt to the comment 1085 commentWithPrefix := func(body string) error { 1086 return comment(fmt.Sprintf("Failed to create a cherry-pick bug in Bugzilla: %s", body)) 1087 } 1088 bug, err := getBug(bc, bugID, log, commentWithPrefix) 1089 if err != nil || bug == nil { 1090 return err 1091 } 1092 if !isBugAllowed(bug, options.AllowedGroups) { 1093 // ignore bugs that are in non-allowed groups for this repo 1094 return nil 1095 } 1096 clones, err := bc.GetClones(bug) 1097 if err != nil { 1098 return comment(formatError("creating a cherry-pick bug in Bugzilla: could not get list of clones", bc.Endpoint(), bug.ID, err)) 1099 } 1100 oldLink := fmt.Sprintf(bugLink, bugID, bc.Endpoint(), bugID) 1101 if options.TargetRelease == nil { 1102 return comment(fmt.Sprintf("Could not make automatic cherrypick of %s for this PR as the target_release is not set for this branch in the bugzilla plugin config. Running refresh:\n/bugzilla refresh", oldLink)) 1103 } 1104 targetRelease := *options.TargetRelease 1105 for _, clone := range clones { 1106 if len(clone.TargetRelease) == 1 && clone.TargetRelease[0] == targetRelease { 1107 newTitle := strings.Replace(e.body, fmt.Sprintf("Bug %d", bugID), fmt.Sprintf("Bug %d", clone.ID), 1) 1108 return comment(fmt.Sprintf("Detected clone of %s with correct target release. Retitling PR to link to clone:\n/retitle %s", oldLink, newTitle)) 1109 } 1110 } 1111 cloneID, err := bc.CloneBug(bug) 1112 if err != nil { 1113 log.WithError(err).Debugf("Failed to clone bug %d", bugID) 1114 return comment(formatError("cloning bug for cherrypick", bc.Endpoint(), bug.ID, err)) 1115 } 1116 cloneLink := fmt.Sprintf(bugLink, cloneID, bc.Endpoint(), cloneID) 1117 // Update the version of the bug to the target release 1118 update := bugzilla.BugUpdate{ 1119 TargetRelease: []string{targetRelease}, 1120 } 1121 err = bc.UpdateBug(cloneID, update) 1122 if err != nil { 1123 log.WithError(err).Debugf("Unable to update target release and dependencies for bug %d", cloneID) 1124 return comment(formatError(fmt.Sprintf("updating cherry-pick bug in Bugzilla: Created cherrypick %s, but encountered error updating target release", cloneLink), bc.Endpoint(), cloneID, err)) 1125 } 1126 // Replace old bugID in title with new cloneID 1127 newTitle, err := updateTitleBugID(e.body, bugID, cloneID) 1128 if err != nil { 1129 log.WithError(err).Errorf("failed to update title bug ID: %v", err) 1130 return comment(formatError(fmt.Sprintf("updating GitHub PR title: Created cherrypick %s, but failed to update GitHub PR title name to match", cloneLink), bc.Endpoint(), cloneID, err)) 1131 } 1132 response := fmt.Sprintf("%s has been cloned as %s. Retitling PR to link against new bug.\n/retitle %s", oldLink, cloneLink, newTitle) 1133 return comment(response) 1134 } 1135 1136 func updateTitleBugID(title string, oldID, newID int) (string, error) { 1137 match := titleMatch.FindString(title) 1138 if match == "" { 1139 return "", fmt.Errorf("failed to identify bug string in title") 1140 } 1141 updatedBug := strings.Replace(match, strconv.Itoa(oldID), strconv.Itoa(newID), 1) 1142 newTitle := titleMatch.ReplaceAllString(title, updatedBug) 1143 return newTitle, nil 1144 } 1145 1146 func bugIDFromTitle(title string) (int, bool, error) { 1147 mat := titleMatch.FindStringSubmatch(title) 1148 if mat == nil { 1149 return 0, true, nil 1150 } 1151 bugID, err := strconv.Atoi(mat[1]) 1152 if err != nil { 1153 // should be impossible based on the regex 1154 return 0, false, fmt.Errorf("Failed to parse bug ID (%s) as int", mat[1]) 1155 } 1156 return bugID, false, nil 1157 } 1158 1159 func getBug(bc bugzilla.Client, bugId int, log *logrus.Entry, comment func(string) error) (*bugzilla.Bug, error) { 1160 bug, err := bc.GetBug(bugId) 1161 if err != nil && !bugzilla.IsNotFound(err) { 1162 log.WithError(err).Warn("Unexpected error searching for Bugzilla bug.") 1163 return nil, comment(formatError("searching", bc.Endpoint(), bugId, err)) 1164 } 1165 if bugzilla.IsNotFound(err) || bug == nil { 1166 log.Debug("No bug found.") 1167 return nil, comment(fmt.Sprintf(`No Bugzilla bug with ID %d exists in the tracker at %s. 1168 Once a valid bug is referenced in the title of this pull request, request a bug refresh with <code>/bugzilla refresh</code>.`, 1169 bugId, bc.Endpoint())) 1170 } 1171 return bug, nil 1172 } 1173 1174 func formatError(action, endpoint string, bugId int, err error) string { 1175 knownErrors := map[string]string{ 1176 "There was an error reported for a GitHub REST call": "The Bugzilla server failed to load data from GitHub when creating the bug. This is usually caused by rate-limiting, please try again later.", 1177 } 1178 var applicable []string 1179 for key, value := range knownErrors { 1180 if strings.Contains(err.Error(), key) { 1181 applicable = append(applicable, value) 1182 1183 } 1184 } 1185 digest := "No known errors were detected, please see the full error message for details." 1186 if len(applicable) > 0 { 1187 digest = "We were able to detect the following conditions from the error:\n\n" 1188 for _, item := range applicable { 1189 digest = fmt.Sprintf("%s- %s\n", digest, item) 1190 } 1191 } 1192 return fmt.Sprintf(`An error was encountered %s for bug %d on the Bugzilla server at %s. %s 1193 1194 <details><summary>Full error message.</summary> 1195 1196 <code> 1197 %v 1198 </code> 1199 1200 </details> 1201 1202 Please contact an administrator to resolve this issue, then request a bug refresh with <code>/bugzilla refresh</code>.`, 1203 action, bugId, endpoint, digest, err) 1204 } 1205 1206 func handleClose(e event, gc githubClient, bc bugzilla.Client, options plugins.BugzillaBranchOptions, log *logrus.Entry) error { 1207 comment := e.comment(gc) 1208 if e.missing { 1209 return nil 1210 } 1211 if options.AddExternalLink != nil && *options.AddExternalLink { 1212 response := fmt.Sprintf(`This pull request references `+bugLink+`. The bug has been updated to no longer refer to the pull request using the external bug tracker.`, e.bugId, bc.Endpoint(), e.bugId) 1213 changed, err := bc.RemovePullRequestAsExternalBug(e.bugId, e.org, e.repo, e.number) 1214 if err != nil { 1215 log.WithError(err).Warn("Unexpected error removing external tracker bug from Bugzilla bug.") 1216 return comment(formatError("removing this pull request from the external tracker bugs", bc.Endpoint(), e.bugId, err)) 1217 } 1218 if options.StateAfterClose != nil { 1219 bug, err := bc.GetBug(e.bugId) 1220 if err != nil { 1221 log.WithError(err).Warn("Unexpected error getting Bugzilla bug.") 1222 return comment(formatError("getting bug", bc.Endpoint(), e.bugId, err)) 1223 } 1224 if bug.Status != "CLOSED" { 1225 links, err := bc.GetExternalBugPRsOnBug(e.bugId) 1226 if err != nil { 1227 log.WithError(err).Warn("Unexpected error getting external tracker bugs for Bugzilla bug.") 1228 return comment(formatError("getting external tracker bugs", bc.Endpoint(), e.bugId, err)) 1229 } 1230 if len(links) == 0 { 1231 bug, err := getBug(bc, e.bugId, log, comment) 1232 if err != nil || bug == nil { 1233 return err 1234 } 1235 if update := options.StateAfterClose.AsBugUpdate(bug); update != nil { 1236 if err := bc.UpdateBug(e.bugId, *update); err != nil { 1237 log.WithError(err).Warn("Unexpected error updating Bugzilla bug.") 1238 return comment(formatError(fmt.Sprintf("updating to the %s state", options.StateAfterClose), bc.Endpoint(), e.bugId, err)) 1239 } 1240 response += fmt.Sprintf(" All external bug links have been closed. The bug has been moved to the %s state.", options.StateAfterClose) 1241 } 1242 bzComment := &bugzilla.CommentCreate{ID: bug.ID, Comment: fmt.Sprintf("Bug status changed to %s as previous linked PR https://github.com/%s/%s/pull/%d has been closed", options.StateAfterClose.Status, e.org, e.repo, e.number), IsPrivate: true} 1243 if _, err := bc.CreateComment(bzComment); err != nil { 1244 response += "\nWarning: Failed to comment on Bugzilla bug with reason for changed state." 1245 } 1246 } 1247 } 1248 } 1249 if changed { 1250 return comment(response) 1251 } 1252 } 1253 return nil 1254 } 1255 1256 func isBugAllowed(bug *bugzilla.Bug, allowedGroups []string) bool { 1257 if len(allowedGroups) == 0 { 1258 return true 1259 } 1260 1261 allowed := sets.New[string](allowedGroups...) 1262 for _, group := range bug.Groups { 1263 if !allowed.Has(group) { 1264 return false 1265 } 1266 } 1267 1268 return true 1269 }