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  }