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 }