sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/jira/jira.go (about) 1 /* 2 Copyright 2020 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 jira 18 19 import ( 20 "context" 21 "encoding/json" 22 "errors" 23 "fmt" 24 stdio "io" 25 "net/http" 26 "net/url" 27 "strings" 28 "sync" 29 30 "github.com/andygrunwald/go-jira" 31 "github.com/hashicorp/go-retryablehttp" 32 "github.com/sirupsen/logrus" 33 "k8s.io/apimachinery/pkg/util/sets" 34 35 "sigs.k8s.io/prow/pkg/version" 36 ) 37 38 // These are all the current valid states for Red Hat bugs in jira 39 const ( 40 StatusNew = "NEW" 41 StatusBacklog = "BACKLOG" 42 StatusAssigned = "ASSIGNED" 43 StatusInProgess = "IN PROGRESS" 44 StatusModified = "MODIFIED" 45 StatusPost = "POST" 46 StatusOnDev = "ON_DEV" 47 StatusOnQA = "ON_QA" 48 StatusVerified = "VERIFIED" 49 StatusReleasePending = "RELEASE PENDING" 50 StatusClosed = "CLOSED" 51 ) 52 53 type Client interface { 54 GetIssue(id string) (*jira.Issue, error) 55 // SearchWithContext will search for tickets according to the jql 56 // Jira API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues 57 SearchWithContext(ctx context.Context, jql string, options *jira.SearchOptions) ([]jira.Issue, *jira.Response, error) 58 UpdateIssue(*jira.Issue) (*jira.Issue, error) 59 CreateIssue(*jira.Issue) (*jira.Issue, error) 60 CreateIssueLink(*jira.IssueLink) error 61 // CloneIssue copies an issue struct, clears unsettable fields, creates a new 62 // issue using the updated struct, and then links the new issue as a clone to 63 // the original. 64 CloneIssue(*jira.Issue) (*jira.Issue, error) 65 GetTransitions(issueID string) ([]jira.Transition, error) 66 DoTransition(issueID, transitionID string) error 67 // UpdateStatus updates an issue's status by identifying the ID of the provided 68 // statusName and then doing the status transition to update the issue. 69 UpdateStatus(issueID, statusName string) error 70 // GetIssueSecurityLevel returns the security level of an issue. If no security level 71 // is set for the issue, the returned SecurityLevel and error will both be nil and 72 // the issue will follow the default project security level. 73 GetIssueSecurityLevel(*jira.Issue) (*SecurityLevel, error) 74 // GetIssueQaContact get the user details for the QA contact. The QA contact is a custom field in Jira 75 GetIssueQaContact(*jira.Issue) (*jira.User, error) 76 // GetIssueTargetVersion get the issue Target Release. The target release is a custom field in Jira 77 GetIssueTargetVersion(issue *jira.Issue) (*[]*jira.Version, error) 78 // FindUser returns all users with a field matching the queryParam (ex: email, display name, etc.) 79 FindUser(queryParam string) ([]*jira.User, error) 80 GetRemoteLinks(id string) ([]jira.RemoteLink, error) 81 AddRemoteLink(id string, link *jira.RemoteLink) (*jira.RemoteLink, error) 82 UpdateRemoteLink(id string, link *jira.RemoteLink) error 83 DeleteLink(id string) error 84 DeleteRemoteLink(issueID string, linkID int) error 85 // DeleteRemoteLinkViaURL identifies and removes a remote link from an issue 86 // the has the provided URL. The returned bool indicates whether a change 87 // was made during the operation as a remote link with the URL not existing 88 // is not consider an error for this function. 89 DeleteRemoteLinkViaURL(issueID, url string) (bool, error) 90 ForPlugin(plugin string) Client 91 AddComment(issueID string, comment *jira.Comment) (*jira.Comment, error) 92 ListProjects() (*jira.ProjectList, error) 93 JiraClient() *jira.Client 94 JiraURL() string 95 Used() bool 96 WithFields(fields logrus.Fields) Client 97 GetProjectVersions(project string) ([]*jira.Version, error) 98 } 99 100 type BasicAuthGenerator func() (username, password string) 101 type BearerAuthGenerator func() (token string) 102 103 type Options struct { 104 BasicAuth BasicAuthGenerator 105 BearerAuth BearerAuthGenerator 106 LogFields logrus.Fields 107 } 108 109 type Option func(*Options) 110 111 func WithBasicAuth(basicAuth BasicAuthGenerator) Option { 112 return func(o *Options) { 113 o.BasicAuth = basicAuth 114 } 115 } 116 117 func WithBearerAuth(token BearerAuthGenerator) Option { 118 return func(o *Options) { 119 o.BearerAuth = token 120 } 121 } 122 123 func WithFields(fields logrus.Fields) Option { 124 return func(o *Options) { 125 o.LogFields = fields 126 } 127 } 128 129 func newJiraClient(endpoint string, o Options, retryingClient *retryablehttp.Client) (*jira.Client, error) { 130 retryingClient.HTTPClient.Transport = &metricsTransport{ 131 upstream: retryingClient.HTTPClient.Transport, 132 pathSimplifier: pathSimplifier().Simplify, 133 recorder: requestResults, 134 } 135 retryingClient.HTTPClient.Transport = userAgentSettingTransport{ 136 userAgent: version.UserAgent(), 137 upstream: retryingClient.HTTPClient.Transport, 138 } 139 140 if o.BasicAuth != nil { 141 retryingClient.HTTPClient.Transport = &basicAuthRoundtripper{ 142 generator: o.BasicAuth, 143 upstream: retryingClient.HTTPClient.Transport, 144 } 145 } 146 147 if o.BearerAuth != nil { 148 retryingClient.HTTPClient.Transport = &bearerAuthRoundtripper{ 149 generator: o.BearerAuth, 150 upstream: retryingClient.HTTPClient.Transport, 151 } 152 } 153 154 return jira.NewClient(retryingClient.StandardClient(), endpoint) 155 } 156 157 func NewClient(endpoint string, opts ...Option) (Client, error) { 158 o := Options{} 159 for _, opt := range opts { 160 opt(&o) 161 } 162 163 log := logrus.WithField("client", "jira") 164 if len(o.LogFields) > 0 { 165 log = log.WithFields(o.LogFields) 166 } 167 retryingClient := retryablehttp.NewClient() 168 usedFlagTransport := &clientUsedTransport{ 169 m: sync.Mutex{}, 170 upstream: retryingClient.HTTPClient.Transport, 171 } 172 retryingClient.HTTPClient.Transport = usedFlagTransport 173 retryingClient.Logger = &retryableHTTPLogrusWrapper{log: log} 174 175 jiraClient, err := newJiraClient(endpoint, o, retryingClient) 176 if err != nil { 177 return nil, err 178 } 179 url := jiraClient.GetBaseURL() 180 return &client{delegate: &delegate{url: url.String(), options: o}, logger: log, upstream: jiraClient, clientUsed: usedFlagTransport}, err 181 } 182 183 type userAgentSettingTransport struct { 184 userAgent string 185 upstream http.RoundTripper 186 } 187 188 func (u userAgentSettingTransport) RoundTrip(r *http.Request) (*http.Response, error) { 189 r.Header.Set("User-Agent", u.userAgent) 190 return u.upstream.RoundTrip(r) 191 } 192 193 type clientUsedTransport struct { 194 used bool 195 m sync.Mutex 196 upstream http.RoundTripper 197 } 198 199 func (c *clientUsedTransport) RoundTrip(r *http.Request) (*http.Response, error) { 200 c.m.Lock() 201 c.used = true 202 c.m.Unlock() 203 return c.upstream.RoundTrip(r) 204 } 205 206 func (c *clientUsedTransport) Used() bool { 207 c.m.Lock() 208 defer c.m.Unlock() 209 return c.used 210 } 211 212 type used interface { 213 Used() bool 214 } 215 216 type client struct { 217 logger *logrus.Entry 218 upstream *jira.Client 219 clientUsed used 220 *delegate 221 } 222 223 // delegate actually does the work to talk to Jira 224 type delegate struct { 225 url string 226 options Options 227 } 228 229 func (jc *client) JiraClient() *jira.Client { 230 return jc.upstream 231 } 232 233 func (jc *client) GetIssue(id string) (*jira.Issue, error) { 234 issue, response, err := jc.upstream.Issue.Get(id, &jira.GetQueryOptions{}) 235 if err != nil { 236 if response != nil && response.StatusCode == http.StatusNotFound { 237 return nil, NotFoundError{err} 238 } 239 return nil, HandleJiraError(response, err) 240 } 241 242 return issue, nil 243 } 244 245 func (jc *client) ListProjects() (*jira.ProjectList, error) { 246 projects, response, err := jc.upstream.Project.GetList() 247 if err != nil { 248 return nil, HandleJiraError(response, err) 249 } 250 return projects, nil 251 } 252 253 func IsNotFound(err error) bool { 254 return errors.Is(err, NotFoundError{}) 255 } 256 257 func NewNotFoundError(err error) error { 258 return NotFoundError{err} 259 } 260 261 type NotFoundError struct { 262 error 263 } 264 265 func (NotFoundError) Is(target error) bool { 266 _, match := target.(NotFoundError) 267 return match 268 } 269 270 func (jc *client) GetRemoteLinks(id string) ([]jira.RemoteLink, error) { 271 result, resp, err := jc.upstream.Issue.GetRemoteLinks(id) 272 if err != nil { 273 return nil, HandleJiraError(resp, err) 274 } 275 return *result, nil 276 } 277 278 func (jc *client) AddRemoteLink(id string, link *jira.RemoteLink) (*jira.RemoteLink, error) { 279 result, resp, err := jc.upstream.Issue.AddRemoteLink(id, link) 280 if err != nil { 281 return nil, fmt.Errorf("failed to add link: %w", HandleJiraError(resp, err)) 282 } 283 return result, nil 284 } 285 286 func (jc *client) DeleteLink(linkID string) error { 287 resp, err := jc.upstream.Issue.DeleteLink(linkID) 288 if err != nil { 289 return HandleJiraError(resp, err) 290 } 291 return nil 292 } 293 294 func (jc *client) UpdateRemoteLink(id string, link *jira.RemoteLink) error { 295 internalLinkId := fmt.Sprint(link.ID) 296 req, err := jc.upstream.NewRequest("PUT", "rest/api/2/issue/"+id+"/remotelink/"+internalLinkId, link) 297 if err != nil { 298 return fmt.Errorf("failed to construct request: %w", err) 299 } 300 resp, err := jc.upstream.Do(req, nil) 301 if resp != nil { 302 defer resp.Body.Close() 303 } 304 if err != nil { 305 return fmt.Errorf("failed to update link: %w", HandleJiraError(resp, err)) 306 } 307 if resp.StatusCode != http.StatusNoContent { 308 return fmt.Errorf("failed to update link: expected status code %d but got %d instead", http.StatusNoContent, resp.StatusCode) 309 } 310 return nil 311 } 312 313 func (jc *client) DeleteRemoteLink(issueID string, linkID int) error { 314 apiEndpoint := fmt.Sprintf("/rest/api/2/issue/%s/remotelink/%d", issueID, linkID) 315 req, err := jc.upstream.NewRequest("DELETE", apiEndpoint, nil) 316 if err != nil { 317 return err 318 } 319 320 // the response should be empty if it is not an error 321 resp, err := jc.upstream.Do(req, nil) 322 if resp != nil { 323 defer resp.Body.Close() 324 } 325 // Status code 204 is a success for this function. On success, there will be an error message of `EOF`, 326 // so in addition to the nil check for the error, we must check the status code. 327 if resp.StatusCode != 204 && err != nil { 328 return HandleJiraError(resp, err) 329 } 330 return nil 331 } 332 333 // DeleteRemoteLinkViaURL identifies and removes a remote link from an issue 334 // the has the provided URL. The returned bool indicates whether a change 335 // was made during the operation as a remote link with the URL not existing 336 // is not consider an error for this function. 337 func DeleteRemoteLinkViaURL(jc Client, issueID, url string) (bool, error) { 338 links, err := jc.GetRemoteLinks(issueID) 339 if err != nil { 340 return false, err 341 } 342 for _, link := range links { 343 if link.Object.URL == url { 344 return true, jc.DeleteRemoteLink(issueID, link.ID) 345 } 346 } 347 return false, fmt.Errorf("could not find remote link on issue with URL `%s`", url) 348 } 349 350 func (jc *client) DeleteRemoteLinkViaURL(issueID, url string) (bool, error) { 351 return DeleteRemoteLinkViaURL(jc, issueID, url) 352 } 353 354 func (jc *client) FindUser(queryParam string) ([]*jira.User, error) { 355 // JIRA's own documentation here is incorrect; it specifies that either 'accountID', 356 // 'query', or 'property' must be used. However, JIRA throws an error unless 'username' 357 // is used. This does a search as if it were supposed to be the query param, so we can use it like that 358 queryString := "username='" + queryParam + "'" 359 queryString = url.PathEscape(queryString) 360 361 apiEndpoint := fmt.Sprintf("/rest/api/2/user/search?%s", queryString) 362 req, err := jc.upstream.NewRequest("GET", apiEndpoint, nil) 363 if err != nil { 364 return nil, err 365 } 366 367 users := []*jira.User{} 368 resp, err := jc.upstream.Do(req, &users) 369 if resp != nil { 370 defer resp.Body.Close() 371 } 372 if err != nil { 373 return nil, HandleJiraError(resp, err) 374 } 375 return users, nil 376 } 377 378 // UpdateStatus updates an issue's status by identifying the ID of the provided 379 // statusName and then doing the status transition using the provided client to update the issue. 380 func UpdateStatus(jc Client, issueID, statusName string) error { 381 transitions, err := jc.GetTransitions(issueID) 382 if err != nil { 383 return err 384 } 385 transitionID := "" 386 var nameList []string 387 for _, transition := range transitions { 388 // JIRA shows all statuses as caps in the UI, but internally has different case; use EqualFold to ignore case 389 if strings.EqualFold(transition.Name, statusName) { 390 transitionID = transition.ID 391 break 392 } 393 nameList = append(nameList, transition.Name) 394 } 395 if transitionID == "" { 396 return fmt.Errorf("No transition status with name `%s` could be found. Please select from the following list: %v", statusName, nameList) 397 } 398 return jc.DoTransition(issueID, transitionID) 399 } 400 401 func (jc *client) UpdateStatus(issueID, statusName string) error { 402 return UpdateStatus(jc, issueID, statusName) 403 } 404 405 func (jc *client) GetTransitions(issueID string) ([]jira.Transition, error) { 406 transitions, resp, err := jc.upstream.Issue.GetTransitions(issueID) 407 if err != nil { 408 return nil, HandleJiraError(resp, err) 409 } 410 return transitions, nil 411 } 412 413 func (jc *client) DoTransition(issueID, transitionID string) error { 414 resp, err := jc.upstream.Issue.DoTransition(issueID, transitionID) 415 if err != nil { 416 return HandleJiraError(resp, err) 417 } 418 return nil 419 } 420 421 func (jc *client) UpdateIssue(issue *jira.Issue) (*jira.Issue, error) { 422 result, resp, err := jc.upstream.Issue.Update(issue) 423 if err != nil { 424 return nil, HandleJiraError(resp, err) 425 } 426 return result, nil 427 } 428 429 func (jc *client) AddComment(issueID string, comment *jira.Comment) (*jira.Comment, error) { 430 result, resp, err := jc.upstream.Issue.AddComment(issueID, comment) 431 if err != nil { 432 return nil, HandleJiraError(resp, err) 433 } 434 return result, nil 435 } 436 437 func (jc *client) CreateIssue(issue *jira.Issue) (*jira.Issue, error) { 438 result, resp, err := jc.upstream.Issue.Create(issue) 439 if err != nil { 440 return nil, HandleJiraError(resp, err) 441 } 442 return result, nil 443 } 444 445 func (jc *client) CreateIssueLink(link *jira.IssueLink) error { 446 resp, err := jc.upstream.Issue.AddLink(link) 447 if err != nil { 448 return HandleJiraError(resp, err) 449 } 450 return nil 451 } 452 453 // CloneIssue copies an issue struct, clears unsettable fields, creates a new 454 // issue using the updated struct, and then links the new issue as a clone to 455 // the original. 456 func CloneIssue(jc Client, parent *jira.Issue) (*jira.Issue, error) { 457 // create deep copy of parent "Fields" field 458 data, err := json.Marshal(parent.Fields) 459 if err != nil { 460 return nil, err 461 } 462 childIssueFields := &jira.IssueFields{} 463 err = json.Unmarshal(data, childIssueFields) 464 if err != nil { 465 return nil, err 466 } 467 childIssue := &jira.Issue{ 468 Fields: childIssueFields, 469 } 470 // update description 471 childIssue.Fields.Description = fmt.Sprintf("This is a clone of issue %s. The following is the description of the original issue: \n---\n%s", parent.Key, parent.Fields.Description) 472 473 // attempt to create the new issue 474 createdIssue, err := jc.CreateIssue(childIssue) 475 if err != nil { 476 // some fields cannot be set on creation; unset them 477 if JiraErrorStatusCode(err) != 400 { 478 return nil, err 479 } 480 var newErr error 481 childIssue, newErr = unsetProblematicFields(childIssue, JiraErrorBody(err)) 482 if newErr != nil { 483 // in this situation, it makes more sense to just return the original error; any error from unsetProblematicFields will be 484 // a json marshalling error, indicating an error different from the standard non-settable fields error. The error from 485 // unsetProblematicFields is not useful in these cases 486 return nil, err 487 } 488 createdIssue, err = jc.CreateIssue(childIssue) 489 if err != nil { 490 return nil, err 491 } 492 } 493 494 // create clone links 495 link := &jira.IssueLink{ 496 OutwardIssue: &jira.Issue{ID: parent.ID}, 497 InwardIssue: &jira.Issue{ID: createdIssue.ID}, 498 Type: jira.IssueLinkType{ 499 Name: "Cloners", 500 Inward: "is cloned by", 501 Outward: "clones", 502 }, 503 } 504 if err := jc.CreateIssueLink(link); err != nil { 505 return nil, err 506 } 507 // Get updated issue, which would have issue links 508 if clonedIssue, err := jc.GetIssue(createdIssue.ID); err != nil { 509 // still return the originally created child issue here in case of failure to get updated issue 510 return createdIssue, fmt.Errorf("Could not get issue after creating issue links: %w", err) 511 } else { 512 return clonedIssue, nil 513 } 514 } 515 516 func (jc *client) CloneIssue(parent *jira.Issue) (*jira.Issue, error) { 517 return CloneIssue(jc, parent) 518 } 519 520 func unsetProblematicFields(issue *jira.Issue, responseBody string) (*jira.Issue, error) { 521 // handle unsettable "unknown" fields 522 processedResponse := CreateIssueError{} 523 if newErr := json.Unmarshal([]byte(responseBody), &processedResponse); newErr != nil { 524 return nil, fmt.Errorf("Error processing jira error: %w", newErr) 525 } 526 // turn issue into map to simplify unsetting process 527 marshalledIssue, err := json.Marshal(issue) 528 if err != nil { 529 return nil, err 530 } 531 issueMap := make(map[string]interface{}) 532 if err := json.Unmarshal(marshalledIssue, &issueMap); err != nil { 533 return nil, err 534 } 535 fieldsMap := issueMap["fields"].(map[string]interface{}) 536 for field := range processedResponse.Errors { 537 delete(fieldsMap, field) 538 } 539 // Remove null value "customfields_" because they cause the server to return: 500 Internal Server Error 540 for field, value := range fieldsMap { 541 if strings.HasPrefix(field, "customfield_") && value == nil { 542 delete(fieldsMap, field) 543 } 544 } 545 issueMap["fields"] = fieldsMap 546 // turn back into jira.Issue type 547 marshalledFixedIssue, err := json.Marshal(issueMap) 548 if err != nil { 549 return nil, err 550 } 551 newIssue := jira.Issue{} 552 if err := json.Unmarshal(marshalledFixedIssue, &newIssue); err != nil { 553 return nil, err 554 } 555 return &newIssue, nil 556 } 557 558 type CreateIssueError struct { 559 ErrorMessages []string `json:"errorMessages"` 560 Errors map[string]string `json:"errors"` 561 } 562 563 type SecurityLevel struct { 564 Self string `json:"self"` 565 ID string `json:"id"` 566 Name string `json:"name"` 567 Description string `json:"description"` 568 } 569 570 // Used determines whether the client has been used 571 func (jc *client) Used() bool { 572 return jc.clientUsed.Used() 573 } 574 575 type bearerAuthRoundtripper struct { 576 generator BearerAuthGenerator 577 upstream http.RoundTripper 578 } 579 580 // WithFields clones the client, keeping the underlying delegate the same but adding 581 // fields to the logging context 582 func (jc *client) WithFields(fields logrus.Fields) Client { 583 return &client{ 584 clientUsed: jc.clientUsed, 585 upstream: jc.upstream, 586 logger: jc.logger.WithFields(fields), 587 delegate: jc.delegate, 588 } 589 } 590 591 // ForPlugin clones the client, keeping the underlying delegate the same but adding 592 // a plugin identifier and log field 593 func (jc *client) ForPlugin(plugin string) Client { 594 pluginLogger := jc.logger.WithField("plugin", plugin) 595 retryingClient := retryablehttp.NewClient() 596 usedFlagTransport := &clientUsedTransport{ 597 m: sync.Mutex{}, 598 upstream: retryingClient.HTTPClient.Transport, 599 } 600 retryingClient.HTTPClient.Transport = usedFlagTransport 601 retryingClient.Logger = &retryableHTTPLogrusWrapper{log: pluginLogger} 602 // ignore error as url.String() was passed to the delegate 603 jiraClient, err := newJiraClient(jc.url, jc.options, retryingClient) 604 if err != nil { 605 pluginLogger.WithError(err).Error("invalid Jira URL") 606 jiraClient = jc.upstream 607 } 608 return &client{ 609 logger: pluginLogger, 610 clientUsed: usedFlagTransport, 611 upstream: jiraClient, 612 delegate: jc.delegate, 613 } 614 } 615 616 func (jc *client) JiraURL() string { 617 return jc.url 618 } 619 620 func (bart *bearerAuthRoundtripper) RoundTrip(req *http.Request) (*http.Response, error) { 621 req2 := new(http.Request) 622 *req2 = *req 623 req2.URL = new(url.URL) 624 *req2.URL = *req.URL 625 token := bart.generator() 626 req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 627 logrus.WithField("curl", toCurl(req2)).Trace("Executing http request") 628 return bart.upstream.RoundTrip(req2) 629 } 630 631 type basicAuthRoundtripper struct { 632 generator BasicAuthGenerator 633 upstream http.RoundTripper 634 } 635 636 func (bart *basicAuthRoundtripper) RoundTrip(req *http.Request) (*http.Response, error) { 637 req2 := new(http.Request) 638 *req2 = *req 639 req2.URL = new(url.URL) 640 *req2.URL = *req.URL 641 user, pass := bart.generator() 642 req2.SetBasicAuth(user, pass) 643 logrus.WithField("curl", toCurl(req2)).Trace("Executing http request") 644 return bart.upstream.RoundTrip(req2) 645 } 646 647 var knownAuthTypes = sets.New[string]("bearer", "basic", "negotiate") 648 649 // maskAuthorizationHeader masks credential content from authorization headers 650 // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization 651 func maskAuthorizationHeader(key string, value string) string { 652 if !strings.EqualFold(key, "Authorization") { 653 return value 654 } 655 if len(value) == 0 { 656 return "" 657 } 658 var authType string 659 if i := strings.Index(value, " "); i > 0 { 660 authType = value[0:i] 661 } else { 662 authType = value 663 } 664 if !knownAuthTypes.Has(strings.ToLower(authType)) { 665 return "<masked>" 666 } 667 if len(value) > len(authType)+1 { 668 value = authType + " <masked>" 669 } else { 670 value = authType 671 } 672 return value 673 } 674 675 type JiraError struct { 676 StatusCode int 677 Body string 678 OriginalError error 679 } 680 681 func (e JiraError) Error() string { 682 return fmt.Sprintf("%s: %s", e.OriginalError, e.Body) 683 } 684 685 // JiraErrorStatusCode will identify if an error is a JiraError and return the 686 // stored status code if it is; if it is not, `-1` will be returned 687 func JiraErrorStatusCode(err error) int { 688 if jiraErr := (&JiraError{}); errors.As(err, &jiraErr) { 689 return jiraErr.StatusCode 690 } 691 jiraErr, ok := err.(*JiraError) 692 if !ok { 693 return -1 694 } 695 return jiraErr.StatusCode 696 } 697 698 // JiraErrorBody will identify if an error is a JiraError and return the stored 699 // response body if it is; if it is not, an empty string will be returned 700 func JiraErrorBody(err error) string { 701 if jiraErr := (&JiraError{}); errors.As(err, &jiraErr) { 702 return jiraErr.Body 703 } 704 jiraErr, ok := err.(*JiraError) 705 if !ok { 706 return "" 707 } 708 return jiraErr.Body 709 } 710 711 // HandleJiraError collapses cryptic Jira errors to include response 712 // bodies if it's detected that the original error holds no 713 // useful context in the first place 714 func HandleJiraError(response *jira.Response, err error) error { 715 if err != nil && strings.Contains(err.Error(), "Please analyze the request body for more details.") { 716 if response != nil && response.Response != nil { 717 body, readError := stdio.ReadAll(response.Body) 718 if readError != nil && readError.Error() != "http: read on closed response body" { 719 logrus.WithError(readError).Warn("Failed to read Jira response body.") 720 } 721 return &JiraError{ 722 StatusCode: response.StatusCode, 723 Body: string(body), 724 OriginalError: err, 725 } 726 } 727 } 728 return err 729 } 730 731 // toCurl is a slightly adjusted copy of https://github.com/kubernetes/kubernetes/blob/74053d555d71a14e3853b97e204d7d6415521375/staging/src/k8s.io/client-go/transport/round_trippers.go#L339 732 func toCurl(r *http.Request) string { 733 headers := "" 734 for key, values := range r.Header { 735 for _, value := range values { 736 headers += fmt.Sprintf(` -H %q`, fmt.Sprintf("%s: %s", key, maskAuthorizationHeader(key, value))) 737 } 738 } 739 740 return fmt.Sprintf("curl -k -v -X%s %s '%s'", r.Method, headers, r.URL.String()) 741 } 742 743 type retryableHTTPLogrusWrapper struct { 744 log *logrus.Entry 745 } 746 747 // fieldsForContext translates a list of context fields to a 748 // logrus format; any items that don't conform to our expectations 749 // are omitted 750 func (l *retryableHTTPLogrusWrapper) fieldsForContext(context ...interface{}) logrus.Fields { 751 fields := logrus.Fields{} 752 for i := 0; i < len(context)-1; i += 2 { 753 key, ok := context[i].(string) 754 if !ok { 755 continue 756 } 757 fields[key] = context[i+1] 758 } 759 return fields 760 } 761 762 func (l *retryableHTTPLogrusWrapper) Error(msg string, context ...interface{}) { 763 l.log.WithFields(l.fieldsForContext(context...)).Error(msg) 764 } 765 766 func (l *retryableHTTPLogrusWrapper) Info(msg string, context ...interface{}) { 767 l.log.WithFields(l.fieldsForContext(context...)).Info(msg) 768 } 769 770 func (l *retryableHTTPLogrusWrapper) Debug(msg string, context ...interface{}) { 771 l.log.WithFields(l.fieldsForContext(context...)).Debug(msg) 772 } 773 774 func (l *retryableHTTPLogrusWrapper) Warn(msg string, context ...interface{}) { 775 l.log.WithFields(l.fieldsForContext(context...)).Warn(msg) 776 } 777 778 func (jc *client) SearchWithContext(ctx context.Context, jql string, options *jira.SearchOptions) ([]jira.Issue, *jira.Response, error) { 779 issues, response, err := jc.upstream.Issue.SearchWithContext(ctx, jql, options) 780 if err != nil { 781 if response != nil && response.StatusCode == http.StatusNotFound { 782 return nil, response, NotFoundError{err} 783 } 784 return nil, response, HandleJiraError(response, err) 785 } 786 return issues, response, nil 787 } 788 789 func GetUnknownField(field string, issue *jira.Issue, fn func() interface{}) error { 790 obj := fn() 791 unknownField, ok := issue.Fields.Unknowns[field] 792 if !ok { 793 return nil 794 } 795 bytes, err := json.Marshal(unknownField) 796 if err != nil { 797 return fmt.Errorf("failed to process the custom field %s. Error : %v", field, err) 798 } 799 if err := json.Unmarshal(bytes, obj); err != nil { 800 return fmt.Errorf("failed to unmarshall the json to struct for %s. Error: %v", field, err) 801 } 802 return err 803 804 } 805 806 // GetIssueSecurityLevel returns the security level of an issue. If no security level 807 // is set for the issue, the returned SecurityLevel and error will both be nil and 808 // the issue will follow the default project security level. 809 func GetIssueSecurityLevel(issue *jira.Issue) (*SecurityLevel, error) { 810 // TODO: Add field to the upstream go-jira package; if a security level exists, it is returned 811 // as part of the issue fields 812 // See https://github.com/andygrunwald/go-jira/issues/456 813 var obj *SecurityLevel 814 err := GetUnknownField("security", issue, func() interface{} { 815 obj = &SecurityLevel{} 816 return obj 817 }) 818 return obj, err 819 } 820 821 func (jc *client) GetIssueSecurityLevel(issue *jira.Issue) (*SecurityLevel, error) { 822 return GetIssueSecurityLevel(issue) 823 } 824 825 func GetIssueQaContact(issue *jira.Issue) (*jira.User, error) { 826 var obj *jira.User 827 err := GetUnknownField("customfield_12316243", issue, func() interface{} { 828 obj = &jira.User{} 829 return obj 830 }) 831 return obj, err 832 } 833 834 func (jc *client) GetIssueQaContact(issue *jira.Issue) (*jira.User, error) { 835 return GetIssueQaContact(issue) 836 } 837 838 func GetIssueTargetVersion(issue *jira.Issue) (*[]*jira.Version, error) { 839 var obj *[]*jira.Version 840 err := GetUnknownField("customfield_12319940", issue, func() interface{} { 841 obj = &[]*jira.Version{{}} 842 return obj 843 }) 844 return obj, err 845 } 846 847 func (jc *client) GetIssueTargetVersion(issue *jira.Issue) (*[]*jira.Version, error) { 848 return GetIssueTargetVersion(issue) 849 } 850 851 // GetProjectVersions returns the list of all the Versions defined in a Project 852 func (jc *client) GetProjectVersions(project string) ([]*jira.Version, error) { 853 req, err := jc.upstream.NewRequest("GET", "rest/api/2/project/"+project+"/versions", nil) 854 if err != nil { 855 return nil, fmt.Errorf("failed to construct request: %w", err) 856 } 857 versions := []*jira.Version{} 858 resp, err := jc.upstream.Do(req, &versions) 859 if resp != nil { 860 defer resp.Body.Close() 861 } 862 if err != nil { 863 return nil, HandleJiraError(resp, err) 864 } 865 return versions, nil 866 }