sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/bugzilla/client.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
    18  
    19  import (
    20  	"bytes"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"net/url"
    26  	"sort"
    27  	"strconv"
    28  	"strings"
    29  	"sync"
    30  	"time"
    31  
    32  	"github.com/prometheus/client_golang/prometheus"
    33  	"github.com/sirupsen/logrus"
    34  	"golang.org/x/sync/errgroup"
    35  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    36  
    37  	"sigs.k8s.io/prow/pkg/version"
    38  )
    39  
    40  const (
    41  	methodField = "method"
    42  )
    43  
    44  type Client interface {
    45  	Endpoint() string
    46  	// GetBug retrieves a Bug from the server
    47  	GetBug(id int) (*Bug, error)
    48  	// GetComments gets a list of comments for a specific bug ID.
    49  	// https://bugzilla.readthedocs.io/en/latest/api/core/v1/comment.html#get-comments
    50  	GetComments(id int) ([]Comment, error)
    51  	// GetExternalBugPRsOnBug retrieves external bugs on a Bug from the server
    52  	// and returns any that reference a Pull Request in GitHub
    53  	// https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#get-bug
    54  	GetExternalBugPRsOnBug(id int) ([]ExternalBug, error)
    55  	// GetSubComponentsOnBug retrieves a the list of SubComponents of the bug.
    56  	// SubComponents are a Red Hat bugzilla specific extra field.
    57  	GetSubComponentsOnBug(id int) (map[string][]string, error)
    58  	// GetClones gets the list of bugs that the provided bug blocks that also have a matching summary.
    59  	GetClones(bug *Bug) ([]*Bug, error)
    60  	// CreateBug creates a new bug on the server.
    61  	// https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#create-bug
    62  	CreateBug(bug *BugCreate) (int, error)
    63  	// CreateComment creates a new bug on the server.
    64  	// https://bugzilla.redhat.com/docs/en/html/api/core/v1/comment.html#create-comments
    65  	CreateComment(bug *CommentCreate) (int, error)
    66  	// CloneBug clones a bug by creating a new bug with the same fields, copying the description, and updating the bug to depend on the original bug
    67  	CloneBug(bug *Bug, mutations ...func(bug *BugCreate)) (int, error)
    68  	// UpdateBug updates the fields of a bug on the server
    69  	// https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#update-bug
    70  	UpdateBug(id int, update BugUpdate) error
    71  	// AddPullRequestAsExternalBug attempts to add a PR to the external tracker list.
    72  	// External bugs are assumed to fall under the type identified by their hostname,
    73  	// so we will provide https://github.com/ here for the URL identifier. We return
    74  	// any error as well as whether a change was actually made.
    75  	// This will be done via JSONRPC:
    76  	// https://bugzilla.redhat.com/docs/en/html/integrating/api/Bugzilla/Extension/ExternalBugs/WebService.html#add-external-bug
    77  	AddPullRequestAsExternalBug(id int, org, repo string, num int) (bool, error)
    78  	// RemovePullRequestAsExternalBug attempts to remove a PR from the external tracker list.
    79  	// External bugs are assumed to fall under the type identified by their hostname,
    80  	// so we will provide https://github.com/ here for the URL identifier. We return
    81  	// any error as well as whether a change was actually made.
    82  	// This will be done via JSONRPC:
    83  	// https://bugzilla.redhat.com/docs/en/html/integrating/api/Bugzilla/Extension/ExternalBugs/WebService.html#remove-external-bug
    84  	RemovePullRequestAsExternalBug(id int, org, repo string, num int) (bool, error)
    85  	// GetAllClones returns all the clones of the bug including itself
    86  	// Differs from GetClones as GetClones only gets the child clones which are one level lower
    87  	GetAllClones(bug *Bug) ([]*Bug, error)
    88  	// GetRootForClone returns the original bug.
    89  	GetRootForClone(bug *Bug) (*Bug, error)
    90  	// SetRoundTripper sets a custom implementation of RoundTripper as the Transport for http.Client
    91  	SetRoundTripper(t http.RoundTripper)
    92  
    93  	// ForPlugin and ForSubcomponent allow for the logger used in the client
    94  	// to be created in a more specific manner when spawning parallel workers
    95  	ForPlugin(plugin string) Client
    96  	ForSubcomponent(subcomponent string) Client
    97  	WithFields(fields logrus.Fields) Client
    98  	Used() bool
    99  
   100  	// SearchBugs returns all bugs that meet the given criteria
   101  	SearchBugs(filters map[string]string) ([]*Bug, error)
   102  }
   103  
   104  // NewClient returns a bugzilla client.
   105  func NewClient(getAPIKey func() []byte, endpoint string, githubExternalTrackerId uint, authMethod string) Client {
   106  	return &client{
   107  		logger: logrus.WithField("client", "bugzilla"),
   108  		delegate: &delegate{
   109  			client:                  &http.Client{},
   110  			endpoint:                endpoint,
   111  			githubExternalTrackerId: githubExternalTrackerId,
   112  			getAPIKey:               getAPIKey,
   113  			authMethod:              authMethod,
   114  		},
   115  	}
   116  }
   117  
   118  // SetRoundTripper sets the Transport in http.Client to a custom RoundTripper
   119  func (c *client) SetRoundTripper(t http.RoundTripper) {
   120  	c.client.Transport = t
   121  }
   122  
   123  // newBugDetailsCache is a constructor for bugDetailsCache
   124  func newBugDetailsCache() *bugDetailsCache {
   125  	return &bugDetailsCache{cache: map[int]Bug{}}
   126  }
   127  
   128  // bugDetailsCache holds the already retrieved bug details
   129  type bugDetailsCache struct {
   130  	cache map[int]Bug
   131  	lock  sync.Mutex
   132  }
   133  
   134  // get retrieves bug details from the cache and is thread safe
   135  func (bd *bugDetailsCache) get(key int) (bug Bug, exists bool) {
   136  	bd.lock.Lock()
   137  	defer bd.lock.Unlock()
   138  	entry, ok := bd.cache[key]
   139  	return entry, ok
   140  }
   141  
   142  // set stores the bug details in the cache and is thread safe
   143  func (bd *bugDetailsCache) set(key int, value Bug) {
   144  	bd.lock.Lock()
   145  	defer bd.lock.Unlock()
   146  	bd.cache[key] = value
   147  }
   148  
   149  // list returns a slice of all bugs in the cache
   150  func (bd *bugDetailsCache) list() []Bug {
   151  	bd.lock.Lock()
   152  	defer bd.lock.Unlock()
   153  	result := make([]Bug, 0, len(bd.cache))
   154  	for _, bug := range bd.cache {
   155  		result = append(result, bug)
   156  	}
   157  	return result
   158  }
   159  
   160  // client interacts with the Bugzilla api.
   161  type client struct {
   162  	// If logger is non-nil, log all method calls with it.
   163  	logger *logrus.Entry
   164  	// identifier is used to add more identification to the user-agent header
   165  	identifier string
   166  	used       bool
   167  	*delegate
   168  }
   169  
   170  // Used determines whether the client has been used
   171  func (c *client) Used() bool {
   172  	return c.used
   173  }
   174  
   175  // ForPlugin clones the client, keeping the underlying delegate the same but adding
   176  // a plugin identifier and log field
   177  func (c *client) ForPlugin(plugin string) Client {
   178  	return c.forKeyValue("plugin", plugin)
   179  }
   180  
   181  // ForSubcomponent clones the client, keeping the underlying delegate the same but adding
   182  // an identifier and log field
   183  func (c *client) ForSubcomponent(subcomponent string) Client {
   184  	return c.forKeyValue("subcomponent", subcomponent)
   185  }
   186  
   187  func (c *client) forKeyValue(key, value string) Client {
   188  	return &client{
   189  		identifier: value,
   190  		logger:     c.logger.WithField(key, value),
   191  		delegate:   c.delegate,
   192  	}
   193  }
   194  
   195  func (c *client) userAgent() string {
   196  	if c.identifier != "" {
   197  		return version.UserAgentWithIdentifier(c.identifier)
   198  	}
   199  	return version.UserAgent()
   200  }
   201  
   202  // WithFields clones the client, keeping the underlying delegate the same but adding
   203  // fields to the logging context
   204  func (c *client) WithFields(fields logrus.Fields) Client {
   205  	return &client{
   206  		logger:   c.logger.WithFields(fields),
   207  		delegate: c.delegate,
   208  	}
   209  }
   210  
   211  // delegate actually does the work to talk to Bugzilla
   212  type delegate struct {
   213  	client                  *http.Client
   214  	endpoint                string
   215  	githubExternalTrackerId uint
   216  	getAPIKey               func() []byte
   217  	authMethod              string
   218  }
   219  
   220  // the client is a Client impl
   221  var _ Client = &client{}
   222  
   223  func (c *client) Endpoint() string {
   224  	return c.endpoint
   225  }
   226  
   227  // GetBug retrieves a Bug from the server
   228  // https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#get-bug
   229  func (c *client) GetBug(id int) (*Bug, error) {
   230  	logger := c.logger.WithFields(logrus.Fields{methodField: "GetBug", "id": id})
   231  	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/bug/%d", c.endpoint, id), nil)
   232  	if err != nil {
   233  		return nil, err
   234  	}
   235  	values := req.URL.Query()
   236  	values.Add("include_fields", "_default")
   237  	// redhat bugzilla docs claim that flags are a default field, but they are actually not returned unless added to include_fields
   238  	values.Add("include_fields", "flags")
   239  	req.URL.RawQuery = values.Encode()
   240  	raw, err := c.request(req, logger)
   241  	if err != nil {
   242  		return nil, err
   243  	}
   244  	var parsedResponse struct {
   245  		Bugs []*Bug `json:"bugs,omitempty"`
   246  	}
   247  	if err := json.Unmarshal(raw, &parsedResponse); err != nil {
   248  		return nil, fmt.Errorf("could not unmarshal response body: %w", err)
   249  	}
   250  	if len(parsedResponse.Bugs) != 1 {
   251  		return nil, fmt.Errorf("did not get one bug, but %d: %v", len(parsedResponse.Bugs), parsedResponse)
   252  	}
   253  	return parsedResponse.Bugs[0], nil
   254  }
   255  
   256  func getClones(c Client, bug *Bug) ([]*Bug, error) {
   257  	var errs []error
   258  	clones := []*Bug{}
   259  	for _, dependentID := range bug.Blocks {
   260  		dependent, err := c.GetBug(dependentID)
   261  		if err != nil {
   262  			errs = append(errs, fmt.Errorf("Failed to get dependent bug #%d: %w", dependentID, err))
   263  			continue
   264  		}
   265  		if dependent.Summary == bug.Summary {
   266  			clones = append(clones, dependent)
   267  		}
   268  	}
   269  	return clones, utilerrors.NewAggregate(errs)
   270  }
   271  
   272  // GetClones gets the list of bugs that the provided bug blocks that also have a matching summary.
   273  func (c *client) GetClones(bug *Bug) ([]*Bug, error) {
   274  	return getClones(c, bug)
   275  }
   276  
   277  // getImmediateParents gets the Immediate parents of bugs with a matching summary
   278  func getImmediateParents(c Client, bug *Bug) ([]*Bug, error) {
   279  	var errs []error
   280  	parents := []*Bug{}
   281  	// One option would be to return as soon as the first parent is found
   282  	// ideally that should be enough, although there is a check in the getRootForClone function to verify this
   283  	// Logs would need to be monitored to verify this behavior
   284  	for _, parentID := range bug.DependsOn {
   285  		parent, err := c.GetBug(parentID)
   286  		if err != nil {
   287  			errs = append(errs, fmt.Errorf("Failed to get parent bug #%d: %w", parentID, err))
   288  			continue
   289  		}
   290  		if parent.Summary == bug.Summary {
   291  			parents = append(parents, parent)
   292  		}
   293  	}
   294  	return parents, utilerrors.NewAggregate(errs)
   295  }
   296  
   297  func getRootForClone(c Client, bug *Bug) (*Bug, error) {
   298  	curr := bug
   299  	var errs []error
   300  	for len(curr.DependsOn) > 0 {
   301  		parent, err := getImmediateParents(c, curr)
   302  		if err != nil {
   303  			errs = append(errs, err)
   304  		}
   305  		switch l := len(parent); {
   306  		case l <= 0:
   307  			return curr, utilerrors.NewAggregate(errs)
   308  		case l == 1:
   309  			curr = parent[0]
   310  		case l > 1:
   311  			curr = parent[0]
   312  			errs = append(errs, fmt.Errorf("More than one parent found for bug #%d", curr.ID))
   313  		}
   314  	}
   315  	return curr, utilerrors.NewAggregate(errs)
   316  }
   317  
   318  // GetRootForClone returns the original bug.
   319  func (c *client) GetRootForClone(bug *Bug) (*Bug, error) {
   320  	return getRootForClone(c, bug)
   321  }
   322  
   323  // GetAllClones returns all the clones of the bug including itself
   324  // Differs from GetClones as GetClones only gets the child clones which are one level lower
   325  func (c *client) GetAllClones(bug *Bug) ([]*Bug, error) {
   326  	bugCache := newBugDetailsCache()
   327  	return getAllClones(c, bug, bugCache)
   328  }
   329  
   330  func getAllClones(c Client, bug *Bug, bugCache *bugDetailsCache) (clones []*Bug, err error) {
   331  
   332  	clones = []*Bug{}
   333  	bugCache.set(bug.ID, *bug)
   334  	err = getAllLinkedBugs(c, bug.ID, bugCache, nil)
   335  	if err != nil {
   336  		return nil, err
   337  	}
   338  	cachedBugs := bugCache.list()
   339  	for index, node := range cachedBugs {
   340  		if node.Summary == bug.Summary {
   341  			clones = append(clones, &cachedBugs[index])
   342  		}
   343  	}
   344  	sort.SliceStable(clones, func(i, j int) bool {
   345  		return clones[i].ID < clones[j].ID
   346  	})
   347  	return clones, nil
   348  }
   349  
   350  // Parallel implementation for getAllClones - spawns threads to go up and down the tree
   351  // Also parallelizes the getBug calls if bug has multiple bugs in DependsOn/Blocks
   352  func getAllLinkedBugs(c Client, bugID int, bugCache *bugDetailsCache, errGroup *errgroup.Group) error {
   353  	var shouldWait bool
   354  	if errGroup == nil {
   355  		shouldWait = true
   356  		errGroup = new(errgroup.Group)
   357  	}
   358  	bugObj, cacheHasBug := bugCache.get(bugID)
   359  	if !cacheHasBug {
   360  		bug, err := c.GetBug(bugID)
   361  		if err != nil {
   362  			return err
   363  		}
   364  		bugObj = *bug
   365  	}
   366  	errGroup.Go(func() error {
   367  		return traverseUp(c, &bugObj, bugCache, errGroup)
   368  	})
   369  	errGroup.Go(func() error {
   370  		return traverseDown(c, &bugObj, bugCache, errGroup)
   371  	})
   372  
   373  	if shouldWait {
   374  		return errGroup.Wait()
   375  	}
   376  	return nil
   377  }
   378  
   379  func traverseUp(c Client, bug *Bug, bugCache *bugDetailsCache, errGroup *errgroup.Group) error {
   380  	for _, dependsOnID := range bug.DependsOn {
   381  		dependsOnID := dependsOnID
   382  		errGroup.Go(func() error {
   383  			_, alreadyFetched := bugCache.get(dependsOnID)
   384  			if alreadyFetched {
   385  				return nil
   386  			}
   387  			parent, err := c.GetBug(dependsOnID)
   388  			if err != nil {
   389  				return err
   390  			}
   391  			bugCache.set(parent.ID, *parent)
   392  			if bug.Summary == parent.Summary {
   393  				return getAllLinkedBugs(c, parent.ID, bugCache, errGroup)
   394  			}
   395  			return nil
   396  		})
   397  	}
   398  	return nil
   399  }
   400  
   401  func traverseDown(c Client, bug *Bug, bugCache *bugDetailsCache, errGroup *errgroup.Group) error {
   402  	for _, childID := range bug.Blocks {
   403  		childID := childID
   404  		errGroup.Go(func() error {
   405  			_, alreadyFetched := bugCache.get(childID)
   406  			if alreadyFetched {
   407  				return nil
   408  			}
   409  			child, err := c.GetBug(childID)
   410  			if err != nil {
   411  				return err
   412  			}
   413  
   414  			bugCache.set(child.ID, *child)
   415  			if bug.Summary == child.Summary {
   416  				return getAllLinkedBugs(c, child.ID, bugCache, errGroup)
   417  			}
   418  			return nil
   419  		})
   420  	}
   421  	return nil
   422  }
   423  
   424  // GetSubComponentsOnBug retrieves a the list of SubComponents of the bug.
   425  // SubComponents are a Red Hat bugzilla specific extra field.
   426  func (c *client) GetSubComponentsOnBug(id int) (map[string][]string, error) {
   427  	logger := c.logger.WithFields(logrus.Fields{methodField: "GetSubComponentsOnBug", "id": id})
   428  	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/bug/%d", c.endpoint, id), nil)
   429  	if err != nil {
   430  		return nil, err
   431  	}
   432  	values := req.URL.Query()
   433  	values.Add("include_fields", "sub_components")
   434  	req.URL.RawQuery = values.Encode()
   435  	raw, err := c.request(req, logger)
   436  	if err != nil {
   437  		return nil, err
   438  	}
   439  	var parsedResponse struct {
   440  		Bugs []struct {
   441  			SubComponents map[string][]string `json:"sub_components"`
   442  		} `json:"bugs"`
   443  	}
   444  	if err := json.Unmarshal(raw, &parsedResponse); err != nil {
   445  		return nil, fmt.Errorf("could not unmarshal response body: %w", err)
   446  	}
   447  	// if there is no subcomponent, return an empty struct
   448  	if parsedResponse.Bugs == nil || len(parsedResponse.Bugs) == 0 {
   449  		return map[string][]string{}, nil
   450  	}
   451  	// make sure there is only 1 bug
   452  	if len(parsedResponse.Bugs) != 1 {
   453  		return nil, fmt.Errorf("did not get one bug, but %d: %v", len(parsedResponse.Bugs), parsedResponse)
   454  	}
   455  	return parsedResponse.Bugs[0].SubComponents, nil
   456  }
   457  
   458  // GetExternalBugPRsOnBug retrieves external bugs on a Bug from the server
   459  // and returns any that reference a Pull Request in GitHub
   460  // https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#get-bug
   461  func (c *client) GetExternalBugPRsOnBug(id int) ([]ExternalBug, error) {
   462  	logger := c.logger.WithFields(logrus.Fields{methodField: "GetExternalBugPRsOnBug", "id": id})
   463  	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/bug/%d", c.endpoint, id), nil)
   464  	if err != nil {
   465  		return nil, err
   466  	}
   467  	values := req.URL.Query()
   468  	values.Add("include_fields", "external_bugs")
   469  	req.URL.RawQuery = values.Encode()
   470  	raw, err := c.request(req, logger)
   471  	if err != nil {
   472  		return nil, err
   473  	}
   474  	var parsedResponse struct {
   475  		Bugs []struct {
   476  			ExternalBugs []ExternalBug `json:"external_bugs"`
   477  		} `json:"bugs"`
   478  	}
   479  	if err := json.Unmarshal(raw, &parsedResponse); err != nil {
   480  		return nil, fmt.Errorf("could not unmarshal response body: %w", err)
   481  	}
   482  	if len(parsedResponse.Bugs) != 1 {
   483  		return nil, fmt.Errorf("did not get one bug, but %d: %v", len(parsedResponse.Bugs), parsedResponse)
   484  	}
   485  	var prs []ExternalBug
   486  	for _, bug := range parsedResponse.Bugs[0].ExternalBugs {
   487  		if bug.BugzillaBugID != id {
   488  			continue
   489  		}
   490  		if bug.Type.URL != "https://github.com/" {
   491  			// TODO: skuznets: figure out how to honor the endpoints given to the GitHub client to support enterprise here
   492  			continue
   493  		}
   494  		org, repo, num, err := PullFromIdentifier(bug.ExternalBugID)
   495  		if IsIdentifierNotForPullErr(err) {
   496  			continue
   497  		}
   498  		if err != nil {
   499  			return nil, fmt.Errorf("could not parse external identifier %q as pull: %w", bug.ExternalBugID, err)
   500  		}
   501  		bug.Org = org
   502  		bug.Repo = repo
   503  		bug.Num = num
   504  		prs = append(prs, bug)
   505  	}
   506  	return prs, nil
   507  }
   508  
   509  // UpdateBug updates the fields of a bug on the server
   510  // https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#update-bug
   511  func (c *client) UpdateBug(id int, update BugUpdate) error {
   512  	logger := c.logger.WithFields(logrus.Fields{methodField: "UpdateBug", "id": id, "update": update})
   513  	body, err := json.Marshal(update)
   514  	if err != nil {
   515  		return fmt.Errorf("failed to marshal update payload: %w", err)
   516  	}
   517  	req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/rest/bug/%d", c.endpoint, id), bytes.NewBuffer(body))
   518  	if err != nil {
   519  		return err
   520  	}
   521  	req.Header.Set("Content-Type", "application/json")
   522  
   523  	_, err = c.request(req, logger)
   524  	return err
   525  }
   526  
   527  // CreateBug creates a new bug on the server.
   528  // https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#create-bug
   529  func (c *client) CreateBug(bug *BugCreate) (int, error) {
   530  	logger := c.logger.WithFields(logrus.Fields{methodField: "CreateBug", "bug": bug})
   531  	body, err := json.Marshal(bug)
   532  	if err != nil {
   533  		return 0, fmt.Errorf("failed to marshal create payload: %w", err)
   534  	}
   535  	req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/rest/bug", c.endpoint), bytes.NewBuffer(body))
   536  	if err != nil {
   537  		return 0, err
   538  	}
   539  	req.Header.Set("Content-Type", "application/json")
   540  	resp, err := c.request(req, logger)
   541  	if err != nil {
   542  		return 0, err
   543  	}
   544  	var idStruct struct {
   545  		ID int `json:"id,omitempty"`
   546  	}
   547  	err = json.Unmarshal(resp, &idStruct)
   548  	if err != nil {
   549  		return 0, fmt.Errorf("failed to unmarshal server response: %w", err)
   550  	}
   551  	return idStruct.ID, nil
   552  }
   553  
   554  func (c *client) CreateComment(comment *CommentCreate) (int, error) {
   555  	logger := c.logger.WithFields(logrus.Fields{methodField: "CreateComment", "bug": comment.ID})
   556  	body, err := json.Marshal(comment)
   557  	if err != nil {
   558  		return 0, fmt.Errorf("failed to marshal create payload: %w", err)
   559  	}
   560  	req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/rest/bug/%d/comment", c.endpoint, comment.ID), bytes.NewBuffer(body))
   561  	if err != nil {
   562  		return 0, err
   563  	}
   564  	req.Header.Set("Content-Type", "application/json")
   565  	resp, err := c.request(req, logger)
   566  	if err != nil {
   567  		return 0, err
   568  	}
   569  	var idStruct struct {
   570  		ID int `json:"id,omitempty"`
   571  	}
   572  	err = json.Unmarshal(resp, &idStruct)
   573  	if err != nil {
   574  		return 0, fmt.Errorf("failed to unmarshal server response: %w", err)
   575  	}
   576  	return idStruct.ID, nil
   577  }
   578  
   579  func cloneBugStruct(bug *Bug, subcomponents map[string][]string, comments []Comment) *BugCreate {
   580  	newBug := &BugCreate{
   581  		Alias:           bug.Alias,
   582  		AssignedTo:      bug.AssignedTo,
   583  		CC:              bug.CC,
   584  		Component:       bug.Component,
   585  		Flags:           bug.Flags,
   586  		Groups:          bug.Groups,
   587  		Keywords:        bug.Keywords,
   588  		OperatingSystem: bug.OperatingSystem,
   589  		Platform:        bug.Platform,
   590  		Priority:        bug.Priority,
   591  		Product:         bug.Product,
   592  		QAContact:       bug.QAContact,
   593  		Severity:        bug.Severity,
   594  		Summary:         bug.Summary,
   595  		TargetMilestone: bug.TargetMilestone,
   596  		Version:         bug.Version,
   597  	}
   598  	if len(subcomponents) > 0 {
   599  		newBug.SubComponents = subcomponents
   600  	}
   601  	for _, comment := range comments {
   602  		if comment.IsPrivate {
   603  			newBug.CommentIsPrivate = true
   604  			break
   605  		}
   606  	}
   607  	var newDesc strings.Builder
   608  	// The following builds a description comprising all the bug's comments formatted the same way that Bugzilla does on clone
   609  	newDesc.WriteString(fmt.Sprintf("+++ This bug was initially created as a clone of Bug #%d +++\n\n", bug.ID))
   610  	if len(comments) > 0 {
   611  		newDesc.WriteString(comments[0].Text)
   612  	}
   613  	// This is a standard time layout string for golang, which formats the time `Mon Jan 2 15:04:05 -0700 MST 2006` to the layout we want
   614  	bzTimeLayout := "2006-01-02 15:04:05 MST"
   615  	for _, comment := range comments[1:] {
   616  		// Header
   617  		newDesc.WriteString("\n\n--- Additional comment from ")
   618  		newDesc.WriteString(comment.Creator)
   619  		newDesc.WriteString(" on ")
   620  		newDesc.WriteString(comment.Time.UTC().Format(bzTimeLayout))
   621  		newDesc.WriteString(" ---\n\n")
   622  
   623  		// Comment
   624  		newDesc.WriteString(comment.Text)
   625  	}
   626  	newBug.Description = newDesc.String()
   627  	// make sure comment isn't above maximum length
   628  	if len(newBug.Description) > 65535 {
   629  		newBug.Description = fmt.Sprint(newBug.Description[:65532], "...")
   630  	}
   631  	return newBug
   632  }
   633  
   634  // clone handles the bz client calls for the bug cloning process and allows us to share the implementation
   635  // between the real and fake client to prevent bugs from accidental discrepencies between the two.
   636  func clone(c Client, bug *Bug, mutations []func(bug *BugCreate)) (int, error) {
   637  	subcomponents, err := c.GetSubComponentsOnBug(bug.ID)
   638  	if err != nil {
   639  		return 0, fmt.Errorf("failed to check if bug has subcomponents: %w", err)
   640  	}
   641  	comments, err := c.GetComments(bug.ID)
   642  	if err != nil {
   643  		return 0, fmt.Errorf("failed to get parent bug's comments: %w", err)
   644  	}
   645  
   646  	newBug := cloneBugStruct(bug, subcomponents, comments)
   647  	for _, mutation := range mutations {
   648  		mutation(newBug)
   649  	}
   650  
   651  	id, err := c.CreateBug(newBug)
   652  	if err != nil {
   653  		return id, err
   654  	}
   655  	bugUpdate := BugUpdate{
   656  		DependsOn: &IDUpdate{
   657  			Add: []int{bug.ID},
   658  		},
   659  		Whiteboard: bug.Whiteboard,
   660  	}
   661  	for _, originalBlocks := range bug.Blocks {
   662  		if bugUpdate.Blocks == nil {
   663  			bugUpdate.Blocks = &IDUpdate{}
   664  		}
   665  		bugUpdate.Blocks.Add = append(bugUpdate.Blocks.Add, originalBlocks)
   666  	}
   667  	err = c.UpdateBug(id, bugUpdate)
   668  	return id, err
   669  }
   670  
   671  // CloneBug clones a bug by creating a new bug with the same fields, copying the description, and updating the bug to depend on the original bug
   672  func (c *client) CloneBug(bug *Bug, mutations ...func(bug *BugCreate)) (int, error) {
   673  	return clone(c, bug, mutations)
   674  }
   675  
   676  // GetComments gets a list of comments for a specific bug ID.
   677  // https://bugzilla.readthedocs.io/en/latest/api/core/v1/comment.html#get-comments
   678  func (c *client) GetComments(bugID int) ([]Comment, error) {
   679  	logger := c.logger.WithFields(logrus.Fields{methodField: "GetComments", "id": bugID})
   680  	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/bug/%d/comment", c.endpoint, bugID), nil)
   681  	if err != nil {
   682  		return nil, err
   683  	}
   684  	raw, err := c.request(req, logger)
   685  	if err != nil {
   686  		return nil, err
   687  	}
   688  	var parsedResponse struct {
   689  		Bugs map[int]struct {
   690  			Comments []Comment `json:"comments,omitempty"`
   691  		} `json:"bugs,omitempty"`
   692  	}
   693  	if err := json.Unmarshal(raw, &parsedResponse); err != nil {
   694  		return nil, fmt.Errorf("could not unmarshal response body: %w", err)
   695  	}
   696  	if len(parsedResponse.Bugs) != 1 {
   697  		return nil, fmt.Errorf("did not get one bug, but %d: %v", len(parsedResponse.Bugs), parsedResponse)
   698  	}
   699  	return parsedResponse.Bugs[bugID].Comments, nil
   700  }
   701  
   702  func (c *client) request(req *http.Request, logger *logrus.Entry) ([]byte, error) {
   703  	c.used = true
   704  	if apiKey := c.getAPIKey(); len(apiKey) > 0 {
   705  		switch c.authMethod {
   706  		case "bearer":
   707  			req.Header.Set("Authorization", "Bearer "+string(apiKey))
   708  		case "query":
   709  			values := req.URL.Query()
   710  			values.Add("api_key", string(apiKey))
   711  			req.URL.RawQuery = values.Encode()
   712  		case "x-bugzilla-api-key":
   713  			req.Header.Set("X-BUGZILLA-API-KEY", string(apiKey))
   714  		default:
   715  			// If there is no auth method specified, we use a union of `query` and
   716  			// `x-bugzilla-api-key` to mimic the previous default behavior which attempted
   717  			// to satisfy different BugZilla server versions.
   718  			req.Header.Set("X-BUGZILLA-API-KEY", string(apiKey))
   719  			values := req.URL.Query()
   720  			values.Add("api_key", string(apiKey))
   721  			req.URL.RawQuery = values.Encode()
   722  		}
   723  	}
   724  	if userAgent := c.userAgent(); userAgent != "" {
   725  		req.Header.Add("User-Agent", userAgent)
   726  	}
   727  	start := time.Now()
   728  	resp, err := c.client.Do(req)
   729  	stop := time.Now()
   730  	promLabels := prometheus.Labels(map[string]string{methodField: logger.Data[methodField].(string), "status": ""})
   731  	if resp != nil {
   732  		promLabels["status"] = strconv.Itoa(resp.StatusCode)
   733  	}
   734  	requestDurations.With(promLabels).Observe(float64(stop.Sub(start).Seconds()))
   735  	if resp != nil {
   736  		logger.WithField("response", resp.StatusCode).Debug("Got response from Bugzilla.")
   737  	}
   738  	if err != nil {
   739  		code := -1
   740  		if resp != nil {
   741  			code = resp.StatusCode
   742  		}
   743  		return nil, &requestError{statusCode: code, message: err.Error()}
   744  	}
   745  	defer func() {
   746  		if err := resp.Body.Close(); err != nil {
   747  			logger.WithError(err).Warn("could not close response body")
   748  		}
   749  	}()
   750  	raw, err := io.ReadAll(resp.Body)
   751  	if err != nil {
   752  		return nil, fmt.Errorf("could not read response body: %w", err)
   753  	}
   754  	var error struct {
   755  		Error   bool   `json:"error"`
   756  		Code    int    `json:"code"`
   757  		Message string `json:"message"`
   758  	}
   759  	if err := json.Unmarshal(raw, &error); err != nil && len(raw) > 0 {
   760  		logger.WithError(err).Debug("could not read response body as error")
   761  	}
   762  	if error.Error {
   763  		return nil, &requestError{statusCode: resp.StatusCode, bugzillaCode: error.Code, message: error.Message}
   764  	} else if resp.StatusCode != http.StatusOK {
   765  		return nil, &requestError{statusCode: resp.StatusCode, message: fmt.Sprintf("response code %d not %d", resp.StatusCode, http.StatusOK)}
   766  	}
   767  	return raw, nil
   768  }
   769  
   770  type requestError struct {
   771  	statusCode   int
   772  	bugzillaCode int
   773  	message      string
   774  }
   775  
   776  func (e requestError) Error() string {
   777  	if e.bugzillaCode != 0 {
   778  		return fmt.Sprintf("code %d: %s", e.bugzillaCode, e.message)
   779  	}
   780  	return e.message
   781  }
   782  
   783  func IsNotFound(err error) bool {
   784  	reqError, ok := err.(*requestError)
   785  	if !ok {
   786  		return false
   787  	}
   788  	return reqError.statusCode == http.StatusNotFound
   789  }
   790  
   791  func IsInvalidBugID(err error) bool {
   792  	reqError, ok := err.(*requestError)
   793  	if !ok {
   794  		return false
   795  	}
   796  	return reqError.bugzillaCode == 101
   797  }
   798  
   799  func IsAccessDenied(err error) bool {
   800  	reqError, ok := err.(*requestError)
   801  	if !ok {
   802  		return false
   803  	}
   804  	if reqError.bugzillaCode == 102 || reqError.statusCode == 401 {
   805  		return true
   806  	}
   807  	return false
   808  }
   809  
   810  // AddPullRequestAsExternalBug attempts to add a PR to the external tracker list.
   811  // External bugs are assumed to fall under the type identified by their hostname,
   812  // so we will provide https://github.com/ here for the URL identifier. We return
   813  // any error as well as whether a change was actually made.
   814  // This will be done via JSONRPC:
   815  // https://bugzilla.redhat.com/docs/en/html/integrating/api/Bugzilla/Extension/ExternalBugs/WebService.html#add-external-bug
   816  func (c *client) AddPullRequestAsExternalBug(id int, org, repo string, num int) (bool, error) {
   817  	logger := c.logger.WithFields(logrus.Fields{methodField: "AddExternalBug", "id": id, "org": org, "repo": repo, "num": num})
   818  	pullIdentifier := IdentifierForPull(org, repo, num)
   819  	bugIdentifier := ExternalBugIdentifier{
   820  		ID: pullIdentifier,
   821  	}
   822  	if c.githubExternalTrackerId != 0 {
   823  		bugIdentifier.TrackerID = int(c.githubExternalTrackerId)
   824  	} else {
   825  		bugIdentifier.Type = "https://github.com/"
   826  	}
   827  	rpcPayload := struct {
   828  		// Version is the version of JSONRPC to use. All Bugzilla servers
   829  		// support 1.0. Some support 1.1 and some support 2.0
   830  		Version string `json:"jsonrpc"`
   831  		Method  string `json:"method"`
   832  		// Parameters must be specified in JSONRPC 1.0 as a structure in the first
   833  		// index of this slice
   834  		Parameters []AddExternalBugParameters `json:"params"`
   835  		ID         string                     `json:"id"`
   836  	}{
   837  		Version: "1.0", // some Bugzilla servers support 2.0 but all support 1.0
   838  		Method:  "ExternalBugs.add_external_bug",
   839  		ID:      "identifier", // this is useful when fielding asynchronous responses, but not here
   840  		Parameters: []AddExternalBugParameters{{
   841  			BugIDs:       []int{id},
   842  			ExternalBugs: []ExternalBugIdentifier{bugIdentifier},
   843  		}},
   844  	}
   845  	body, err := json.Marshal(rpcPayload)
   846  	if err != nil {
   847  		return false, fmt.Errorf("failed to marshal JSONRPC payload: %w", err)
   848  	}
   849  	req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/jsonrpc.cgi", c.endpoint), bytes.NewBuffer(body))
   850  	if err != nil {
   851  		return false, err
   852  	}
   853  	req.Header.Set("Content-Type", "application/json")
   854  
   855  	resp, err := c.request(req, logger)
   856  	if err != nil {
   857  		return false, err
   858  	}
   859  	var response struct {
   860  		Error *struct {
   861  			Code    int    `json:"code"`
   862  			Message string `json:"message"`
   863  		} `json:"error,omitempty"`
   864  		ID     string `json:"id"`
   865  		Result *struct {
   866  			Bugs []struct {
   867  				ID      int `json:"id"`
   868  				Changes struct {
   869  					ExternalBugs struct {
   870  						Added   string `json:"added"`
   871  						Removed string `json:"removed"`
   872  					} `json:"ext_bz_bug_map.ext_bz_bug_id"`
   873  				} `json:"changes"`
   874  			} `json:"bugs"`
   875  		} `json:"result,omitempty"`
   876  	}
   877  	if err := json.Unmarshal(resp, &response); err != nil {
   878  		return false, fmt.Errorf("failed to unmarshal JSONRPC response: %w", err)
   879  	}
   880  	if response.Error != nil {
   881  		if response.Error.Code == 100500 && strings.Contains(response.Error.Message, `duplicate key value violates unique constraint "ext_bz_bug_map_bug_id_idx"`) {
   882  			// adding the external bug failed since it is already added, this is not an error
   883  			return false, nil
   884  		}
   885  		return false, fmt.Errorf("JSONRPC error %d: %v", response.Error.Code, response.Error.Message)
   886  	}
   887  	if response.ID != rpcPayload.ID {
   888  		return false, fmt.Errorf("JSONRPC returned mismatched identifier, expected %s but got %s", rpcPayload.ID, response.ID)
   889  	}
   890  	changed := false
   891  	if response.Result != nil {
   892  		for _, bug := range response.Result.Bugs {
   893  			if bug.ID == id {
   894  				changed = changed || strings.Contains(bug.Changes.ExternalBugs.Added, pullIdentifier)
   895  			}
   896  		}
   897  	}
   898  	return changed, nil
   899  }
   900  
   901  // RemovePullRequestAsExternalBug attempts to remove a PR from the external tracker list.
   902  // External bugs are assumed to fall under the type identified by their hostname,
   903  // so we will provide https://github.com/ here for the URL identifier. We return
   904  // any error as well as whether a change was actually made.
   905  // This will be done via JSONRPC:
   906  // https://bugzilla.redhat.com/docs/en/html/integrating/api/Bugzilla/Extension/ExternalBugs/WebService.html#remove-external-bug
   907  func (c *client) RemovePullRequestAsExternalBug(id int, org, repo string, num int) (bool, error) {
   908  	logger := c.logger.WithFields(logrus.Fields{methodField: "RemoveExternalBug", "id": id, "org": org, "repo": repo, "num": num})
   909  	pullIdentifier := IdentifierForPull(org, repo, num)
   910  	rpcPayload := struct {
   911  		// Version is the version of JSONRPC to use. All Bugzilla servers
   912  		// support 1.0. Some support 1.1 and some support 2.0
   913  		Version string `json:"jsonrpc"`
   914  		Method  string `json:"method"`
   915  		// Parameters must be specified in JSONRPC 1.0 as a structure in the first
   916  		// index of this slice
   917  		Parameters []RemoveExternalBugParameters `json:"params"`
   918  		ID         string                        `json:"id"`
   919  	}{
   920  		Version: "1.0", // some Bugzilla servers support 2.0 but all support 1.0
   921  		Method:  "ExternalBugs.remove_external_bug",
   922  		ID:      "identifier", // this is useful when fielding asynchronous responses, but not here
   923  		Parameters: []RemoveExternalBugParameters{{
   924  			BugIDs: []int{id},
   925  			ExternalBugIdentifier: ExternalBugIdentifier{
   926  				Type: "https://github.com/",
   927  				ID:   pullIdentifier,
   928  			},
   929  		}},
   930  	}
   931  	body, err := json.Marshal(rpcPayload)
   932  	if err != nil {
   933  		return false, fmt.Errorf("failed to marshal JSONRPC payload: %w", err)
   934  	}
   935  	req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/jsonrpc.cgi", c.endpoint), bytes.NewBuffer(body))
   936  	if err != nil {
   937  		return false, err
   938  	}
   939  	req.Header.Set("Content-Type", "application/json")
   940  
   941  	resp, err := c.request(req, logger)
   942  	if err != nil {
   943  		return false, err
   944  	}
   945  	var response struct {
   946  		Error *struct {
   947  			Code    int    `json:"code"`
   948  			Message string `json:"message"`
   949  		} `json:"error,omitempty"`
   950  		ID     string `json:"id"`
   951  		Result *struct {
   952  			ExternalBugs []struct {
   953  				Type string `json:"ext_type_url"`
   954  				ID   string `json:"ext_bz_bug_id"`
   955  			} `json:"external_bugs"`
   956  		} `json:"result,omitempty"`
   957  	}
   958  	if err := json.Unmarshal(resp, &response); err != nil {
   959  		return false, fmt.Errorf("failed to unmarshal JSONRPC response: %w", err)
   960  	}
   961  	if response.Error != nil {
   962  		if response.Error.Code == 1006 && strings.Contains(response.Error.Message, `No external tracker bugs were found that matched your criteria`) {
   963  			// removing the external bug failed since it is already gone, this is not an error
   964  			return false, nil
   965  		}
   966  		return false, fmt.Errorf("JSONRPC error %d: %v", response.Error.Code, response.Error.Message)
   967  	}
   968  	if response.ID != rpcPayload.ID {
   969  		return false, fmt.Errorf("JSONRPC returned mismatched identifier, expected %s but got %s", rpcPayload.ID, response.ID)
   970  	}
   971  	changed := false
   972  	if response.Result != nil {
   973  		for _, bug := range response.Result.ExternalBugs {
   974  			changed = changed || bug.ID == pullIdentifier
   975  		}
   976  	}
   977  	return changed, nil
   978  }
   979  
   980  func IdentifierForPull(org, repo string, num int) string {
   981  	return fmt.Sprintf("%s/%s/pull/%d", org, repo, num)
   982  }
   983  
   984  func PullFromIdentifier(identifier string) (org, repo string, num int, err error) {
   985  	parts := strings.Split(identifier, "/")
   986  	if len(parts) >= 3 && parts[2] != "pull" {
   987  		return "", "", 0, &identifierNotForPull{identifier: identifier}
   988  	}
   989  	if len(parts) != 4 && !(len(parts) == 5 && (parts[4] == "" || parts[4] == "files")) && !(len(parts) == 6 && (parts[4] == "files" && parts[5] == "")) {
   990  		return "", "", 0, fmt.Errorf("invalid pull identifier with %d parts: %q", len(parts), identifier)
   991  	}
   992  	number, err := strconv.Atoi(parts[3])
   993  	if err != nil {
   994  		return "", "", 0, fmt.Errorf("invalid pull identifier: could not parse %s as number: %w", parts[3], err)
   995  	}
   996  
   997  	return parts[0], parts[1], number, nil
   998  }
   999  
  1000  type identifierNotForPull struct {
  1001  	identifier string
  1002  }
  1003  
  1004  func (i identifierNotForPull) Error() string {
  1005  	return fmt.Sprintf("identifier %q is not for a pull request", i.identifier)
  1006  }
  1007  
  1008  func IsIdentifierNotForPullErr(err error) bool {
  1009  	_, ok := err.(*identifierNotForPull)
  1010  	return ok
  1011  }
  1012  
  1013  func (c *client) SearchBugs(filters map[string]string) ([]*Bug, error) {
  1014  	logger := c.logger.WithFields(logrus.Fields{methodField: "SearchBugs", "filters": filters})
  1015  
  1016  	params := url.Values{}
  1017  	for param, value := range filters {
  1018  		params.Add(param, value)
  1019  	}
  1020  
  1021  	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/bug?%s", c.endpoint, params.Encode()), nil)
  1022  	if err != nil {
  1023  		return nil, err
  1024  	}
  1025  	raw, err := c.request(req, logger)
  1026  	if err != nil {
  1027  		return nil, err
  1028  	}
  1029  	var parsedResponse struct {
  1030  		Bugs []*Bug `json:"bugs,omitempty"`
  1031  	}
  1032  	if err := json.Unmarshal(raw, &parsedResponse); err != nil {
  1033  		return nil, fmt.Errorf("could not unmarshal response body: %v", err)
  1034  	}
  1035  	return parsedResponse.Bugs, nil
  1036  }