github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/api/queries_pr.go (about) 1 package api 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "net/http" 9 "strings" 10 "time" 11 12 "github.com/cli/cli/internal/ghinstance" 13 "github.com/cli/cli/internal/ghrepo" 14 "github.com/cli/cli/pkg/set" 15 "github.com/shurcooL/githubv4" 16 "golang.org/x/sync/errgroup" 17 ) 18 19 type PullRequestsPayload struct { 20 ViewerCreated PullRequestAndTotalCount 21 ReviewRequested PullRequestAndTotalCount 22 CurrentPR *PullRequest 23 DefaultBranch string 24 } 25 26 type PullRequestAndTotalCount struct { 27 TotalCount int 28 PullRequests []PullRequest 29 } 30 31 type PullRequest struct { 32 ID string 33 Number int 34 Title string 35 State string 36 Closed bool 37 URL string 38 BaseRefName string 39 HeadRefName string 40 Body string 41 Mergeable string 42 Additions int 43 Deletions int 44 ChangedFiles int 45 MergeStateStatus string 46 CreatedAt time.Time 47 UpdatedAt time.Time 48 ClosedAt *time.Time 49 MergedAt *time.Time 50 51 MergeCommit *Commit 52 PotentialMergeCommit *Commit 53 54 Files struct { 55 Nodes []PullRequestFile 56 } 57 58 Author Author 59 MergedBy *Author 60 HeadRepositoryOwner Owner 61 HeadRepository *PRRepository 62 IsCrossRepository bool 63 IsDraft bool 64 MaintainerCanModify bool 65 66 BaseRef struct { 67 BranchProtectionRule struct { 68 RequiresStrictStatusChecks bool 69 } 70 } 71 72 ReviewDecision string 73 74 Commits struct { 75 TotalCount int 76 Nodes []PullRequestCommit 77 } 78 StatusCheckRollup struct { 79 Nodes []struct { 80 Commit struct { 81 StatusCheckRollup struct { 82 Contexts struct { 83 Nodes []struct { 84 TypeName string `json:"__typename"` 85 Name string `json:"name"` 86 Context string `json:"context,omitempty"` 87 State string `json:"state,omitempty"` 88 Status string `json:"status"` 89 Conclusion string `json:"conclusion"` 90 StartedAt time.Time `json:"startedAt"` 91 CompletedAt time.Time `json:"completedAt"` 92 DetailsURL string `json:"detailsUrl"` 93 TargetURL string `json:"targetUrl,omitempty"` 94 } 95 PageInfo struct { 96 HasNextPage bool 97 EndCursor string 98 } 99 } 100 } 101 } 102 } 103 } 104 105 Assignees Assignees 106 Labels Labels 107 ProjectCards ProjectCards 108 Milestone *Milestone 109 Comments Comments 110 ReactionGroups ReactionGroups 111 Reviews PullRequestReviews 112 ReviewRequests ReviewRequests 113 } 114 115 type PRRepository struct { 116 ID string `json:"id"` 117 Name string `json:"name"` 118 } 119 120 // Commit loads just the commit SHA and nothing else 121 type Commit struct { 122 OID string `json:"oid"` 123 } 124 125 type PullRequestCommit struct { 126 Commit PullRequestCommitCommit 127 } 128 129 // PullRequestCommitCommit contains full information about a commit 130 type PullRequestCommitCommit struct { 131 OID string `json:"oid"` 132 Authors struct { 133 Nodes []struct { 134 Name string 135 Email string 136 User GitHubUser 137 } 138 } 139 MessageHeadline string 140 MessageBody string 141 CommittedDate time.Time 142 AuthoredDate time.Time 143 } 144 145 type PullRequestFile struct { 146 Path string `json:"path"` 147 Additions int `json:"additions"` 148 Deletions int `json:"deletions"` 149 } 150 151 type ReviewRequests struct { 152 Nodes []struct { 153 RequestedReviewer RequestedReviewer 154 } 155 } 156 157 type RequestedReviewer struct { 158 TypeName string `json:"__typename"` 159 Login string `json:"login"` 160 Name string `json:"name"` 161 Slug string `json:"slug"` 162 Organization struct { 163 Login string `json:"login"` 164 } `json:"organization"` 165 } 166 167 func (r RequestedReviewer) LoginOrSlug() string { 168 if r.TypeName == teamTypeName { 169 return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) 170 } 171 return r.Login 172 } 173 174 const teamTypeName = "Team" 175 176 func (r ReviewRequests) Logins() []string { 177 logins := make([]string, len(r.Nodes)) 178 for i, r := range r.Nodes { 179 logins[i] = r.RequestedReviewer.LoginOrSlug() 180 } 181 return logins 182 } 183 184 func (pr PullRequest) HeadLabel() string { 185 if pr.IsCrossRepository { 186 return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName) 187 } 188 return pr.HeadRefName 189 } 190 191 func (pr PullRequest) Link() string { 192 return pr.URL 193 } 194 195 func (pr PullRequest) Identifier() string { 196 return pr.ID 197 } 198 199 func (pr PullRequest) IsOpen() bool { 200 return pr.State == "OPEN" 201 } 202 203 type PullRequestReviewStatus struct { 204 ChangesRequested bool 205 Approved bool 206 ReviewRequired bool 207 } 208 209 func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus { 210 var status PullRequestReviewStatus 211 switch pr.ReviewDecision { 212 case "CHANGES_REQUESTED": 213 status.ChangesRequested = true 214 case "APPROVED": 215 status.Approved = true 216 case "REVIEW_REQUIRED": 217 status.ReviewRequired = true 218 } 219 return status 220 } 221 222 type PullRequestChecksStatus struct { 223 Pending int 224 Failing int 225 Passing int 226 Total int 227 } 228 229 func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { 230 if len(pr.StatusCheckRollup.Nodes) == 0 { 231 return 232 } 233 commit := pr.StatusCheckRollup.Nodes[0].Commit 234 for _, c := range commit.StatusCheckRollup.Contexts.Nodes { 235 state := c.State // StatusContext 236 if state == "" { 237 // CheckRun 238 if c.Status == "COMPLETED" { 239 state = c.Conclusion 240 } else { 241 state = c.Status 242 } 243 } 244 switch state { 245 case "SUCCESS", "NEUTRAL", "SKIPPED": 246 summary.Passing++ 247 case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": 248 summary.Failing++ 249 default: // "EXPECTED", "REQUESTED", "WAITING", "QUEUED", "PENDING", "IN_PROGRESS", "STALE" 250 summary.Pending++ 251 } 252 summary.Total++ 253 } 254 return 255 } 256 257 func (pr *PullRequest) DisplayableReviews() PullRequestReviews { 258 published := []PullRequestReview{} 259 for _, prr := range pr.Reviews.Nodes { 260 //Dont display pending reviews 261 //Dont display commenting reviews without top level comment body 262 if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") { 263 published = append(published, prr) 264 } 265 } 266 return PullRequestReviews{Nodes: published, TotalCount: len(published)} 267 } 268 269 func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) { 270 url := fmt.Sprintf("%srepos/%s/pulls/%d", 271 ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber) 272 req, err := http.NewRequest("GET", url, nil) 273 if err != nil { 274 return nil, err 275 } 276 277 req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8") 278 279 resp, err := c.http.Do(req) 280 if err != nil { 281 return nil, err 282 } 283 284 if resp.StatusCode == 404 { 285 return nil, errors.New("pull request not found") 286 } else if resp.StatusCode != 200 { 287 return nil, HandleHTTPError(resp) 288 } 289 290 return resp.Body, nil 291 } 292 293 type pullRequestFeature struct { 294 HasReviewDecision bool 295 HasStatusCheckRollup bool 296 HasBranchProtectionRule bool 297 } 298 299 func determinePullRequestFeatures(httpClient *http.Client, hostname string) (prFeatures pullRequestFeature, err error) { 300 if !ghinstance.IsEnterprise(hostname) { 301 prFeatures.HasReviewDecision = true 302 prFeatures.HasStatusCheckRollup = true 303 prFeatures.HasBranchProtectionRule = true 304 return 305 } 306 307 var featureDetection struct { 308 PullRequest struct { 309 Fields []struct { 310 Name string 311 } `graphql:"fields(includeDeprecated: true)"` 312 } `graphql:"PullRequest: __type(name: \"PullRequest\")"` 313 Commit struct { 314 Fields []struct { 315 Name string 316 } `graphql:"fields(includeDeprecated: true)"` 317 } `graphql:"Commit: __type(name: \"Commit\")"` 318 } 319 320 // needs to be a separate query because the backend only supports 2 `__type` expressions in one query 321 var featureDetection2 struct { 322 Ref struct { 323 Fields []struct { 324 Name string 325 } `graphql:"fields(includeDeprecated: true)"` 326 } `graphql:"Ref: __type(name: \"Ref\")"` 327 } 328 329 v4 := graphQLClient(httpClient, hostname) 330 331 g := new(errgroup.Group) 332 g.Go(func() error { 333 return v4.QueryNamed(context.Background(), "PullRequest_fields", &featureDetection, nil) 334 }) 335 g.Go(func() error { 336 return v4.QueryNamed(context.Background(), "PullRequest_fields2", &featureDetection2, nil) 337 }) 338 339 err = g.Wait() 340 if err != nil { 341 return 342 } 343 344 for _, field := range featureDetection.PullRequest.Fields { 345 switch field.Name { 346 case "reviewDecision": 347 prFeatures.HasReviewDecision = true 348 } 349 } 350 for _, field := range featureDetection.Commit.Fields { 351 switch field.Name { 352 case "statusCheckRollup": 353 prFeatures.HasStatusCheckRollup = true 354 } 355 } 356 for _, field := range featureDetection2.Ref.Fields { 357 switch field.Name { 358 case "branchProtectionRule": 359 prFeatures.HasBranchProtectionRule = true 360 } 361 } 362 return 363 } 364 365 type StatusOptions struct { 366 CurrentPR int 367 HeadRef string 368 Username string 369 Fields []string 370 } 371 372 func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOptions) (*PullRequestsPayload, error) { 373 type edges struct { 374 TotalCount int 375 Edges []struct { 376 Node PullRequest 377 } 378 } 379 380 type response struct { 381 Repository struct { 382 DefaultBranchRef struct { 383 Name string 384 } 385 PullRequests edges 386 PullRequest *PullRequest 387 } 388 ViewerCreated edges 389 ReviewRequested edges 390 } 391 392 var fragments string 393 if len(options.Fields) > 0 { 394 fields := set.NewStringSet() 395 fields.AddValues(options.Fields) 396 // these are always necessary to find the PR for the current branch 397 fields.AddValues([]string{"isCrossRepository", "headRepositoryOwner", "headRefName"}) 398 gr := PullRequestGraphQL(fields.ToSlice()) 399 fragments = fmt.Sprintf("fragment pr on PullRequest{%s}fragment prWithReviews on PullRequest{...pr}", gr) 400 } else { 401 var err error 402 fragments, err = pullRequestFragment(client.http, repo.RepoHost()) 403 if err != nil { 404 return nil, err 405 } 406 } 407 408 queryPrefix := ` 409 query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { 410 repository(owner: $owner, name: $repo) { 411 defaultBranchRef { 412 name 413 } 414 pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) { 415 totalCount 416 edges { 417 node { 418 ...prWithReviews 419 } 420 } 421 } 422 } 423 ` 424 if options.CurrentPR > 0 { 425 queryPrefix = ` 426 query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { 427 repository(owner: $owner, name: $repo) { 428 defaultBranchRef { 429 name 430 } 431 pullRequest(number: $number) { 432 ...prWithReviews 433 } 434 } 435 ` 436 } 437 438 query := fragments + queryPrefix + ` 439 viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) { 440 totalCount: issueCount 441 edges { 442 node { 443 ...prWithReviews 444 } 445 } 446 } 447 reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) { 448 totalCount: issueCount 449 edges { 450 node { 451 ...pr 452 } 453 } 454 } 455 } 456 ` 457 458 currentUsername := options.Username 459 if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) { 460 var err error 461 currentUsername, err = CurrentLoginName(client, repo.RepoHost()) 462 if err != nil { 463 return nil, err 464 } 465 } 466 467 viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername) 468 reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername) 469 470 currentPRHeadRef := options.HeadRef 471 branchWithoutOwner := currentPRHeadRef 472 if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 { 473 branchWithoutOwner = currentPRHeadRef[idx+1:] 474 } 475 476 variables := map[string]interface{}{ 477 "viewerQuery": viewerQuery, 478 "reviewerQuery": reviewerQuery, 479 "owner": repo.RepoOwner(), 480 "repo": repo.RepoName(), 481 "headRefName": branchWithoutOwner, 482 "number": options.CurrentPR, 483 } 484 485 var resp response 486 err := client.GraphQL(repo.RepoHost(), query, variables, &resp) 487 if err != nil { 488 return nil, err 489 } 490 491 var viewerCreated []PullRequest 492 for _, edge := range resp.ViewerCreated.Edges { 493 viewerCreated = append(viewerCreated, edge.Node) 494 } 495 496 var reviewRequested []PullRequest 497 for _, edge := range resp.ReviewRequested.Edges { 498 reviewRequested = append(reviewRequested, edge.Node) 499 } 500 501 var currentPR = resp.Repository.PullRequest 502 if currentPR == nil { 503 for _, edge := range resp.Repository.PullRequests.Edges { 504 if edge.Node.HeadLabel() == currentPRHeadRef { 505 currentPR = &edge.Node 506 break // Take the most recent PR for the current branch 507 } 508 } 509 } 510 511 payload := PullRequestsPayload{ 512 ViewerCreated: PullRequestAndTotalCount{ 513 PullRequests: viewerCreated, 514 TotalCount: resp.ViewerCreated.TotalCount, 515 }, 516 ReviewRequested: PullRequestAndTotalCount{ 517 PullRequests: reviewRequested, 518 TotalCount: resp.ReviewRequested.TotalCount, 519 }, 520 CurrentPR: currentPR, 521 DefaultBranch: resp.Repository.DefaultBranchRef.Name, 522 } 523 524 return &payload, nil 525 } 526 527 func pullRequestFragment(httpClient *http.Client, hostname string) (string, error) { 528 cachedClient := NewCachedClient(httpClient, time.Hour*24) 529 prFeatures, err := determinePullRequestFeatures(cachedClient, hostname) 530 if err != nil { 531 return "", err 532 } 533 534 fields := []string{ 535 "number", "title", "state", "url", "isDraft", "isCrossRepository", 536 "headRefName", "headRepositoryOwner", "mergeStateStatus", 537 } 538 if prFeatures.HasStatusCheckRollup { 539 fields = append(fields, "statusCheckRollup") 540 } 541 if prFeatures.HasBranchProtectionRule { 542 fields = append(fields, "requiresStrictStatusChecks") 543 } 544 545 var reviewFields []string 546 if prFeatures.HasReviewDecision { 547 reviewFields = append(reviewFields, "reviewDecision") 548 } 549 550 fragments := fmt.Sprintf(` 551 fragment pr on PullRequest {%s} 552 fragment prWithReviews on PullRequest {...pr,%s} 553 `, PullRequestGraphQL(fields), PullRequestGraphQL(reviewFields)) 554 return fragments, nil 555 } 556 557 // CreatePullRequest creates a pull request in a GitHub repository 558 func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) { 559 query := ` 560 mutation PullRequestCreate($input: CreatePullRequestInput!) { 561 createPullRequest(input: $input) { 562 pullRequest { 563 id 564 url 565 } 566 } 567 }` 568 569 inputParams := map[string]interface{}{ 570 "repositoryId": repo.ID, 571 } 572 for key, val := range params { 573 switch key { 574 case "title", "body", "draft", "baseRefName", "headRefName", "maintainerCanModify": 575 inputParams[key] = val 576 } 577 } 578 variables := map[string]interface{}{ 579 "input": inputParams, 580 } 581 582 result := struct { 583 CreatePullRequest struct { 584 PullRequest PullRequest 585 } 586 }{} 587 588 err := client.GraphQL(repo.RepoHost(), query, variables, &result) 589 if err != nil { 590 return nil, err 591 } 592 pr := &result.CreatePullRequest.PullRequest 593 594 // metadata parameters aren't currently available in `createPullRequest`, 595 // but they are in `updatePullRequest` 596 updateParams := make(map[string]interface{}) 597 for key, val := range params { 598 switch key { 599 case "assigneeIds", "labelIds", "projectIds", "milestoneId": 600 if !isBlank(val) { 601 updateParams[key] = val 602 } 603 } 604 } 605 if len(updateParams) > 0 { 606 updateQuery := ` 607 mutation PullRequestCreateMetadata($input: UpdatePullRequestInput!) { 608 updatePullRequest(input: $input) { clientMutationId } 609 }` 610 updateParams["pullRequestId"] = pr.ID 611 variables := map[string]interface{}{ 612 "input": updateParams, 613 } 614 err := client.GraphQL(repo.RepoHost(), updateQuery, variables, &result) 615 if err != nil { 616 return pr, err 617 } 618 } 619 620 // reviewers are requested in yet another additional mutation 621 reviewParams := make(map[string]interface{}) 622 if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) { 623 reviewParams["userIds"] = ids 624 } 625 if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) { 626 reviewParams["teamIds"] = ids 627 } 628 629 //TODO: How much work to extract this into own method and use for create and edit? 630 if len(reviewParams) > 0 { 631 reviewQuery := ` 632 mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) { 633 requestReviews(input: $input) { clientMutationId } 634 }` 635 reviewParams["pullRequestId"] = pr.ID 636 reviewParams["union"] = true 637 variables := map[string]interface{}{ 638 "input": reviewParams, 639 } 640 err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result) 641 if err != nil { 642 return pr, err 643 } 644 } 645 646 return pr, nil 647 } 648 649 func UpdatePullRequest(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestInput) error { 650 var mutation struct { 651 UpdatePullRequest struct { 652 PullRequest struct { 653 ID string 654 } 655 } `graphql:"updatePullRequest(input: $input)"` 656 } 657 variables := map[string]interface{}{"input": params} 658 gql := graphQLClient(client.http, repo.RepoHost()) 659 err := gql.MutateNamed(context.Background(), "PullRequestUpdate", &mutation, variables) 660 return err 661 } 662 663 func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params githubv4.RequestReviewsInput) error { 664 var mutation struct { 665 RequestReviews struct { 666 PullRequest struct { 667 ID string 668 } 669 } `graphql:"requestReviews(input: $input)"` 670 } 671 variables := map[string]interface{}{"input": params} 672 gql := graphQLClient(client.http, repo.RepoHost()) 673 err := gql.MutateNamed(context.Background(), "PullRequestUpdateRequestReviews", &mutation, variables) 674 return err 675 } 676 677 func isBlank(v interface{}) bool { 678 switch vv := v.(type) { 679 case string: 680 return vv == "" 681 case []string: 682 return len(vv) == 0 683 default: 684 return true 685 } 686 } 687 688 func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) error { 689 var mutation struct { 690 ClosePullRequest struct { 691 PullRequest struct { 692 ID githubv4.ID 693 } 694 } `graphql:"closePullRequest(input: $input)"` 695 } 696 697 variables := map[string]interface{}{ 698 "input": githubv4.ClosePullRequestInput{ 699 PullRequestID: pr.ID, 700 }, 701 } 702 703 gql := graphQLClient(client.http, repo.RepoHost()) 704 err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables) 705 706 return err 707 } 708 709 func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) error { 710 var mutation struct { 711 ReopenPullRequest struct { 712 PullRequest struct { 713 ID githubv4.ID 714 } 715 } `graphql:"reopenPullRequest(input: $input)"` 716 } 717 718 variables := map[string]interface{}{ 719 "input": githubv4.ReopenPullRequestInput{ 720 PullRequestID: pr.ID, 721 }, 722 } 723 724 gql := graphQLClient(client.http, repo.RepoHost()) 725 err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables) 726 727 return err 728 } 729 730 func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error { 731 var mutation struct { 732 MarkPullRequestReadyForReview struct { 733 PullRequest struct { 734 ID githubv4.ID 735 } 736 } `graphql:"markPullRequestReadyForReview(input: $input)"` 737 } 738 739 variables := map[string]interface{}{ 740 "input": githubv4.MarkPullRequestReadyForReviewInput{ 741 PullRequestID: pr.ID, 742 }, 743 } 744 745 gql := graphQLClient(client.http, repo.RepoHost()) 746 return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables) 747 } 748 749 func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error { 750 path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) 751 return client.REST(repo.RepoHost(), "DELETE", path, nil, nil) 752 }