github.com/secman-team/gh-api@v1.8.2/api/queries_pr.go (about)

     1  package api
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"sort"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/secman-team/gh-api/core/ghinstance"
    14  	"github.com/secman-team/gh-api/core/ghrepo"
    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  	MergeStateStatus string
    45  
    46  	Author struct {
    47  		Login string
    48  	}
    49  	HeadRepositoryOwner struct {
    50  		Login string
    51  	}
    52  	HeadRepository struct {
    53  		Name             string
    54  		DefaultBranchRef struct {
    55  			Name string
    56  		}
    57  	}
    58  	IsCrossRepository   bool
    59  	IsDraft             bool
    60  	MaintainerCanModify bool
    61  
    62  	BaseRef struct {
    63  		BranchProtectionRule struct {
    64  			RequiresStrictStatusChecks bool
    65  		}
    66  	}
    67  
    68  	ReviewDecision string
    69  
    70  	Commits struct {
    71  		TotalCount int
    72  		Nodes      []struct {
    73  			Commit struct {
    74  				Oid               string
    75  				StatusCheckRollup struct {
    76  					Contexts struct {
    77  						Nodes []struct {
    78  							Name        string
    79  							Context     string
    80  							State       string
    81  							Status      string
    82  							Conclusion  string
    83  							StartedAt   time.Time
    84  							CompletedAt time.Time
    85  							DetailsURL  string
    86  							TargetURL   string
    87  						}
    88  					}
    89  				}
    90  			}
    91  		}
    92  	}
    93  	Assignees      Assignees
    94  	Labels         Labels
    95  	ProjectCards   ProjectCards
    96  	Milestone      Milestone
    97  	Comments       Comments
    98  	ReactionGroups ReactionGroups
    99  	Reviews        PullRequestReviews
   100  	ReviewRequests ReviewRequests
   101  }
   102  
   103  type ReviewRequests struct {
   104  	Nodes []struct {
   105  		RequestedReviewer struct {
   106  			TypeName string `json:"__typename"`
   107  			Login    string
   108  			Name     string
   109  		}
   110  	}
   111  	TotalCount int
   112  }
   113  
   114  func (r ReviewRequests) Logins() []string {
   115  	logins := make([]string, len(r.Nodes))
   116  	for i, a := range r.Nodes {
   117  		logins[i] = a.RequestedReviewer.Login
   118  	}
   119  	return logins
   120  }
   121  
   122  type NotFoundError struct {
   123  	error
   124  }
   125  
   126  func (err *NotFoundError) Unwrap() error {
   127  	return err.error
   128  }
   129  
   130  func (pr PullRequest) HeadLabel() string {
   131  	if pr.IsCrossRepository {
   132  		return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName)
   133  	}
   134  	return pr.HeadRefName
   135  }
   136  
   137  func (pr PullRequest) Link() string {
   138  	return pr.URL
   139  }
   140  
   141  func (pr PullRequest) Identifier() string {
   142  	return pr.ID
   143  }
   144  
   145  type PullRequestReviewStatus struct {
   146  	ChangesRequested bool
   147  	Approved         bool
   148  	ReviewRequired   bool
   149  }
   150  
   151  func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
   152  	var status PullRequestReviewStatus
   153  	switch pr.ReviewDecision {
   154  	case "CHANGES_REQUESTED":
   155  		status.ChangesRequested = true
   156  	case "APPROVED":
   157  		status.Approved = true
   158  	case "REVIEW_REQUIRED":
   159  		status.ReviewRequired = true
   160  	}
   161  	return status
   162  }
   163  
   164  type PullRequestChecksStatus struct {
   165  	Pending int
   166  	Failing int
   167  	Passing int
   168  	Total   int
   169  }
   170  
   171  func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
   172  	if len(pr.Commits.Nodes) == 0 {
   173  		return
   174  	}
   175  	commit := pr.Commits.Nodes[0].Commit
   176  	for _, c := range commit.StatusCheckRollup.Contexts.Nodes {
   177  		state := c.State // StatusContext
   178  		if state == "" {
   179  			// CheckRun
   180  			if c.Status == "COMPLETED" {
   181  				state = c.Conclusion
   182  			} else {
   183  				state = c.Status
   184  			}
   185  		}
   186  		switch state {
   187  		case "SUCCESS", "NEUTRAL", "SKIPPED":
   188  			summary.Passing++
   189  		case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED":
   190  			summary.Failing++
   191  		default: // "EXPECTED", "REQUESTED", "WAITING", "QUEUED", "PENDING", "IN_PROGRESS", "STALE"
   192  			summary.Pending++
   193  		}
   194  		summary.Total++
   195  	}
   196  	return
   197  }
   198  
   199  func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
   200  	published := []PullRequestReview{}
   201  	for _, prr := range pr.Reviews.Nodes {
   202  		//Dont display pending reviews
   203  		//Dont display commenting reviews without top level comment body
   204  		if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
   205  			published = append(published, prr)
   206  		}
   207  	}
   208  	return PullRequestReviews{Nodes: published, TotalCount: len(published)}
   209  }
   210  
   211  func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
   212  	url := fmt.Sprintf("%srepos/%s/pulls/%d",
   213  		ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
   214  	req, err := http.NewRequest("GET", url, nil)
   215  	if err != nil {
   216  		return nil, err
   217  	}
   218  
   219  	req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8")
   220  
   221  	resp, err := c.http.Do(req)
   222  	if err != nil {
   223  		return nil, err
   224  	}
   225  
   226  	if resp.StatusCode == 404 {
   227  		return nil, &NotFoundError{errors.New("pull request not found")}
   228  	} else if resp.StatusCode != 200 {
   229  		return nil, HandleHTTPError(resp)
   230  	}
   231  
   232  	return resp.Body, nil
   233  }
   234  
   235  type pullRequestFeature struct {
   236  	HasReviewDecision       bool
   237  	HasStatusCheckRollup    bool
   238  	HasBranchProtectionRule bool
   239  }
   240  
   241  func determinePullRequestFeatures(httpClient *http.Client, hostname string) (prFeatures pullRequestFeature, err error) {
   242  	if !ghinstance.IsEnterprise(hostname) {
   243  		prFeatures.HasReviewDecision = true
   244  		prFeatures.HasStatusCheckRollup = true
   245  		prFeatures.HasBranchProtectionRule = true
   246  		return
   247  	}
   248  
   249  	var featureDetection struct {
   250  		PullRequest struct {
   251  			Fields []struct {
   252  				Name string
   253  			} `graphql:"fields(includeDeprecated: true)"`
   254  		} `graphql:"PullRequest: __type(name: \"PullRequest\")"`
   255  		Commit struct {
   256  			Fields []struct {
   257  				Name string
   258  			} `graphql:"fields(includeDeprecated: true)"`
   259  		} `graphql:"Commit: __type(name: \"Commit\")"`
   260  	}
   261  
   262  	// needs to be a separate query because the backend only supports 2 `__type` expressions in one query
   263  	var featureDetection2 struct {
   264  		Ref struct {
   265  			Fields []struct {
   266  				Name string
   267  			} `graphql:"fields(includeDeprecated: true)"`
   268  		} `graphql:"Ref: __type(name: \"Ref\")"`
   269  	}
   270  
   271  	v4 := graphQLClient(httpClient, hostname)
   272  
   273  	g := new(errgroup.Group)
   274  	g.Go(func() error {
   275  		return v4.QueryNamed(context.Background(), "PullRequest_fields", &featureDetection, nil)
   276  	})
   277  	g.Go(func() error {
   278  		return v4.QueryNamed(context.Background(), "PullRequest_fields2", &featureDetection2, nil)
   279  	})
   280  
   281  	err = g.Wait()
   282  	if err != nil {
   283  		return
   284  	}
   285  
   286  	for _, field := range featureDetection.PullRequest.Fields {
   287  		switch field.Name {
   288  		case "reviewDecision":
   289  			prFeatures.HasReviewDecision = true
   290  		}
   291  	}
   292  	for _, field := range featureDetection.Commit.Fields {
   293  		switch field.Name {
   294  		case "statusCheckRollup":
   295  			prFeatures.HasStatusCheckRollup = true
   296  		}
   297  	}
   298  	for _, field := range featureDetection2.Ref.Fields {
   299  		switch field.Name {
   300  		case "branchProtectionRule":
   301  			prFeatures.HasBranchProtectionRule = true
   302  		}
   303  	}
   304  	return
   305  }
   306  
   307  func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) {
   308  	type edges struct {
   309  		TotalCount int
   310  		Edges      []struct {
   311  			Node PullRequest
   312  		}
   313  	}
   314  
   315  	type response struct {
   316  		Repository struct {
   317  			DefaultBranchRef struct {
   318  				Name string
   319  			}
   320  			PullRequests edges
   321  			PullRequest  *PullRequest
   322  		}
   323  		ViewerCreated   edges
   324  		ReviewRequested edges
   325  	}
   326  
   327  	cachedClient := NewCachedClient(client.http, time.Hour*24)
   328  	prFeatures, err := determinePullRequestFeatures(cachedClient, repo.RepoHost())
   329  	if err != nil {
   330  		return nil, err
   331  	}
   332  
   333  	var reviewsFragment string
   334  	if prFeatures.HasReviewDecision {
   335  		reviewsFragment = "reviewDecision"
   336  	}
   337  
   338  	var statusesFragment string
   339  	if prFeatures.HasStatusCheckRollup {
   340  		statusesFragment = `
   341  		commits(last: 1) {
   342  			nodes {
   343  				commit {
   344  					statusCheckRollup {
   345  						contexts(last: 100) {
   346  							nodes {
   347  								...on StatusContext {
   348  									state
   349  								}
   350  								...on CheckRun {
   351  									conclusion
   352  									status
   353  								}
   354  							}
   355  						}
   356  					}
   357  				}
   358  			}
   359  		}
   360  		`
   361  	}
   362  
   363  	var requiresStrictStatusChecks string
   364  	if prFeatures.HasBranchProtectionRule {
   365  		requiresStrictStatusChecks = `
   366  		baseRef {
   367  			branchProtectionRule {
   368  				requiresStrictStatusChecks
   369  			}
   370  		}`
   371  	}
   372  
   373  	fragments := fmt.Sprintf(`
   374  	fragment pr on PullRequest {
   375  		number
   376  		title
   377  		state
   378  		url
   379  		headRefName
   380  		mergeStateStatus
   381  		headRepositoryOwner {
   382  			login
   383  		}
   384  		%s
   385  		isCrossRepository
   386  		isDraft
   387  		%s
   388  	}
   389  	fragment prWithReviews on PullRequest {
   390  		...pr
   391  		%s
   392  	}
   393  	`, requiresStrictStatusChecks, statusesFragment, reviewsFragment)
   394  
   395  	queryPrefix := `
   396  	query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
   397  		repository(owner: $owner, name: $repo) {
   398  			defaultBranchRef { 
   399  				name 
   400  			}
   401  			pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) {
   402  				totalCount
   403  				edges {
   404  					node {
   405  						...prWithReviews
   406  					}
   407  				}
   408  			}
   409  		}
   410  	`
   411  	if currentPRNumber > 0 {
   412  		queryPrefix = `
   413  		query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
   414  			repository(owner: $owner, name: $repo) {
   415  				defaultBranchRef { 
   416  					name 
   417  				}
   418  				pullRequest(number: $number) {
   419  					...prWithReviews
   420  				}
   421  			}
   422  		`
   423  	}
   424  
   425  	query := fragments + queryPrefix + `
   426        viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) {
   427         totalCount: issueCount
   428          edges {
   429            node {
   430              ...prWithReviews
   431            }
   432          }
   433        }
   434        reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) {
   435          totalCount: issueCount
   436          edges {
   437            node {
   438              ...pr
   439            }
   440          }
   441        }
   442      }
   443  	`
   444  
   445  	if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) {
   446  		currentUsername, err = CurrentLoginName(client, repo.RepoHost())
   447  		if err != nil {
   448  			return nil, err
   449  		}
   450  	}
   451  
   452  	viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername)
   453  	reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername)
   454  
   455  	branchWithoutOwner := currentPRHeadRef
   456  	if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 {
   457  		branchWithoutOwner = currentPRHeadRef[idx+1:]
   458  	}
   459  
   460  	variables := map[string]interface{}{
   461  		"viewerQuery":   viewerQuery,
   462  		"reviewerQuery": reviewerQuery,
   463  		"owner":         repo.RepoOwner(),
   464  		"repo":          repo.RepoName(),
   465  		"headRefName":   branchWithoutOwner,
   466  		"number":        currentPRNumber,
   467  	}
   468  
   469  	var resp response
   470  	err = client.GraphQL(repo.RepoHost(), query, variables, &resp)
   471  	if err != nil {
   472  		return nil, err
   473  	}
   474  
   475  	var viewerCreated []PullRequest
   476  	for _, edge := range resp.ViewerCreated.Edges {
   477  		viewerCreated = append(viewerCreated, edge.Node)
   478  	}
   479  
   480  	var reviewRequested []PullRequest
   481  	for _, edge := range resp.ReviewRequested.Edges {
   482  		reviewRequested = append(reviewRequested, edge.Node)
   483  	}
   484  
   485  	var currentPR = resp.Repository.PullRequest
   486  	if currentPR == nil {
   487  		for _, edge := range resp.Repository.PullRequests.Edges {
   488  			if edge.Node.HeadLabel() == currentPRHeadRef {
   489  				currentPR = &edge.Node
   490  				break // Take the most recent PR for the current branch
   491  			}
   492  		}
   493  	}
   494  
   495  	payload := PullRequestsPayload{
   496  		ViewerCreated: PullRequestAndTotalCount{
   497  			PullRequests: viewerCreated,
   498  			TotalCount:   resp.ViewerCreated.TotalCount,
   499  		},
   500  		ReviewRequested: PullRequestAndTotalCount{
   501  			PullRequests: reviewRequested,
   502  			TotalCount:   resp.ReviewRequested.TotalCount,
   503  		},
   504  		CurrentPR:     currentPR,
   505  		DefaultBranch: resp.Repository.DefaultBranchRef.Name,
   506  	}
   507  
   508  	return &payload, nil
   509  }
   510  
   511  func prCommitsFragment(httpClient *http.Client, hostname string) (string, error) {
   512  	cachedClient := NewCachedClient(httpClient, time.Hour*24)
   513  	if prFeatures, err := determinePullRequestFeatures(cachedClient, hostname); err != nil {
   514  		return "", err
   515  	} else if !prFeatures.HasStatusCheckRollup {
   516  		return "", nil
   517  	}
   518  
   519  	return `
   520  	commits(last: 1) {
   521  		totalCount
   522  		nodes {
   523  			commit {
   524  				oid
   525  				statusCheckRollup {
   526  					contexts(last: 100) {
   527  						nodes {
   528  							...on StatusContext {
   529  								context
   530  								state
   531  								targetUrl
   532  							}
   533  							...on CheckRun {
   534  								name
   535  								status
   536  								conclusion
   537  								startedAt
   538  								completedAt
   539  								detailsUrl
   540  							}
   541  						}
   542  					}
   543  				}
   544  			}
   545  		}
   546  	}
   547  	`, nil
   548  }
   549  
   550  func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*PullRequest, error) {
   551  	type response struct {
   552  		Repository struct {
   553  			PullRequest PullRequest
   554  		}
   555  	}
   556  
   557  	statusesFragment, err := prCommitsFragment(client.http, repo.RepoHost())
   558  	if err != nil {
   559  		return nil, err
   560  	}
   561  
   562  	query := `
   563  	query PullRequestByNumber($owner: String!, $repo: String!, $pr_number: Int!) {
   564  		repository(owner: $owner, name: $repo) {
   565  			pullRequest(number: $pr_number) {
   566  				id
   567  				url
   568  				number
   569  				title
   570  				state
   571  				closed
   572  				body
   573  				mergeable
   574  				additions
   575  				deletions
   576  				author {
   577  				  login
   578  				}
   579  				` + statusesFragment + `
   580  				baseRefName
   581  				headRefName
   582  				headRepositoryOwner {
   583  					login
   584  				}
   585  				headRepository {
   586  					name
   587  				}
   588  				isCrossRepository
   589  				isDraft
   590  				maintainerCanModify
   591  				reviewRequests(first: 100) {
   592  					nodes {
   593  						requestedReviewer {
   594  							__typename
   595  							...on User {
   596  								login
   597  							}
   598  							...on Team {
   599  								name
   600  							}
   601  						}
   602  					}
   603  					totalCount
   604  				}
   605  				assignees(first: 100) {
   606  					nodes {
   607  						login
   608  					}
   609  					totalCount
   610  				}
   611  				labels(first: 100) {
   612  					nodes {
   613  						name
   614  					}
   615  					totalCount
   616  				}
   617  				projectCards(first: 100) {
   618  					nodes {
   619  						project {
   620  							name
   621  						}
   622  						column {
   623  							name
   624  						}
   625  					}
   626  					totalCount
   627  				}
   628  				milestone{
   629  					title
   630  				}
   631  				` + commentsFragment() + `
   632  				` + reactionGroupsFragment() + `
   633  			}
   634  		}
   635  	}`
   636  
   637  	variables := map[string]interface{}{
   638  		"owner":     repo.RepoOwner(),
   639  		"repo":      repo.RepoName(),
   640  		"pr_number": number,
   641  	}
   642  
   643  	var resp response
   644  	err = client.GraphQL(repo.RepoHost(), query, variables, &resp)
   645  	if err != nil {
   646  		return nil, err
   647  	}
   648  
   649  	return &resp.Repository.PullRequest, nil
   650  }
   651  
   652  func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters []string) (*PullRequest, error) {
   653  	type response struct {
   654  		Repository struct {
   655  			PullRequests struct {
   656  				Nodes []PullRequest
   657  			}
   658  		}
   659  	}
   660  
   661  	statusesFragment, err := prCommitsFragment(client.http, repo.RepoHost())
   662  	if err != nil {
   663  		return nil, err
   664  	}
   665  
   666  	query := `
   667  	query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) {
   668  		repository(owner: $owner, name: $repo) {
   669  			pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) {
   670  				nodes {
   671  					id
   672  					number
   673  					title
   674  					state
   675  					body
   676  					mergeable
   677  					additions
   678  					deletions
   679  					author {
   680  						login
   681  					}
   682  					` + statusesFragment + `
   683  					url
   684  					baseRefName
   685  					headRefName
   686  					headRepositoryOwner {
   687  						login
   688  					}
   689  					headRepository {
   690  						name
   691  					}
   692  					isCrossRepository
   693  					isDraft
   694  					maintainerCanModify
   695  					reviewRequests(first: 100) {
   696  						nodes {
   697  							requestedReviewer {
   698  								__typename
   699  								...on User {
   700  									login
   701  								}
   702  								...on Team {
   703  									name
   704  								}
   705  							}
   706  						}
   707  						totalCount
   708  					}
   709  					assignees(first: 100) {
   710  						nodes {
   711  							login
   712  						}
   713  						totalCount
   714  					}
   715  					labels(first: 100) {
   716  						nodes {
   717  							name
   718  						}
   719  						totalCount
   720  					}
   721  					projectCards(first: 100) {
   722  						nodes {
   723  							project {
   724  								name
   725  							}
   726  							column {
   727  								name
   728  							}
   729  						}
   730  						totalCount
   731  					}
   732  					milestone{
   733  						title
   734  					}
   735  					` + commentsFragment() + `
   736  					` + reactionGroupsFragment() + `
   737  				}
   738  			}
   739  		}
   740  	}`
   741  
   742  	branchWithoutOwner := headBranch
   743  	if idx := strings.Index(headBranch, ":"); idx >= 0 {
   744  		branchWithoutOwner = headBranch[idx+1:]
   745  	}
   746  
   747  	variables := map[string]interface{}{
   748  		"owner":       repo.RepoOwner(),
   749  		"repo":        repo.RepoName(),
   750  		"headRefName": branchWithoutOwner,
   751  		"states":      stateFilters,
   752  	}
   753  
   754  	var resp response
   755  	err = client.GraphQL(repo.RepoHost(), query, variables, &resp)
   756  	if err != nil {
   757  		return nil, err
   758  	}
   759  
   760  	prs := resp.Repository.PullRequests.Nodes
   761  	sortPullRequestsByState(prs)
   762  
   763  	for _, pr := range prs {
   764  		if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) {
   765  			return &pr, nil
   766  		}
   767  	}
   768  
   769  	return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)}
   770  }
   771  
   772  // sortPullRequestsByState sorts a PullRequest slice by open-first
   773  func sortPullRequestsByState(prs []PullRequest) {
   774  	sort.SliceStable(prs, func(a, b int) bool {
   775  		return prs[a].State == "OPEN"
   776  	})
   777  }
   778  
   779  // CreatePullRequest creates a pull request in a GitHub repository
   780  func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) {
   781  	query := `
   782  		mutation PullRequestCreate($input: CreatePullRequestInput!) {
   783  			createPullRequest(input: $input) {
   784  				pullRequest {
   785  					id
   786  					url
   787  				}
   788  			}
   789  	}`
   790  
   791  	inputParams := map[string]interface{}{
   792  		"repositoryId": repo.ID,
   793  	}
   794  	for key, val := range params {
   795  		switch key {
   796  		case "title", "body", "draft", "baseRefName", "headRefName", "maintainerCanModify":
   797  			inputParams[key] = val
   798  		}
   799  	}
   800  	variables := map[string]interface{}{
   801  		"input": inputParams,
   802  	}
   803  
   804  	result := struct {
   805  		CreatePullRequest struct {
   806  			PullRequest PullRequest
   807  		}
   808  	}{}
   809  
   810  	err := client.GraphQL(repo.RepoHost(), query, variables, &result)
   811  	if err != nil {
   812  		return nil, err
   813  	}
   814  	pr := &result.CreatePullRequest.PullRequest
   815  
   816  	// metadata parameters aren't currently available in `createPullRequest`,
   817  	// but they are in `updatePullRequest`
   818  	updateParams := make(map[string]interface{})
   819  	for key, val := range params {
   820  		switch key {
   821  		case "assigneeIds", "labelIds", "projectIds", "milestoneId":
   822  			if !isBlank(val) {
   823  				updateParams[key] = val
   824  			}
   825  		}
   826  	}
   827  	if len(updateParams) > 0 {
   828  		updateQuery := `
   829  		mutation PullRequestCreateMetadata($input: UpdatePullRequestInput!) {
   830  			updatePullRequest(input: $input) { clientMutationId }
   831  		}`
   832  		updateParams["pullRequestId"] = pr.ID
   833  		variables := map[string]interface{}{
   834  			"input": updateParams,
   835  		}
   836  		err := client.GraphQL(repo.RepoHost(), updateQuery, variables, &result)
   837  		if err != nil {
   838  			return pr, err
   839  		}
   840  	}
   841  
   842  	// reviewers are requested in yet another additional mutation
   843  	reviewParams := make(map[string]interface{})
   844  	if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) {
   845  		reviewParams["userIds"] = ids
   846  	}
   847  	if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) {
   848  		reviewParams["teamIds"] = ids
   849  	}
   850  
   851  	//TODO: How much work to extract this into own method and use for create and edit?
   852  	if len(reviewParams) > 0 {
   853  		reviewQuery := `
   854  		mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) {
   855  			requestReviews(input: $input) { clientMutationId }
   856  		}`
   857  		reviewParams["pullRequestId"] = pr.ID
   858  		reviewParams["union"] = true
   859  		variables := map[string]interface{}{
   860  			"input": reviewParams,
   861  		}
   862  		err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
   863  		if err != nil {
   864  			return pr, err
   865  		}
   866  	}
   867  
   868  	return pr, nil
   869  }
   870  
   871  func UpdatePullRequest(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestInput) error {
   872  	var mutation struct {
   873  		UpdatePullRequest struct {
   874  			PullRequest struct {
   875  				ID string
   876  			}
   877  		} `graphql:"updatePullRequest(input: $input)"`
   878  	}
   879  	variables := map[string]interface{}{"input": params}
   880  	gql := graphQLClient(client.http, repo.RepoHost())
   881  	err := gql.MutateNamed(context.Background(), "PullRequestUpdate", &mutation, variables)
   882  	return err
   883  }
   884  
   885  func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params githubv4.RequestReviewsInput) error {
   886  	var mutation struct {
   887  		RequestReviews struct {
   888  			PullRequest struct {
   889  				ID string
   890  			}
   891  		} `graphql:"requestReviews(input: $input)"`
   892  	}
   893  	variables := map[string]interface{}{"input": params}
   894  	gql := graphQLClient(client.http, repo.RepoHost())
   895  	err := gql.MutateNamed(context.Background(), "PullRequestUpdateRequestReviews", &mutation, variables)
   896  	return err
   897  }
   898  
   899  func isBlank(v interface{}) bool {
   900  	switch vv := v.(type) {
   901  	case string:
   902  		return vv == ""
   903  	case []string:
   904  		return len(vv) == 0
   905  	default:
   906  		return true
   907  	}
   908  }
   909  
   910  func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
   911  	var mutation struct {
   912  		ClosePullRequest struct {
   913  			PullRequest struct {
   914  				ID githubv4.ID
   915  			}
   916  		} `graphql:"closePullRequest(input: $input)"`
   917  	}
   918  
   919  	variables := map[string]interface{}{
   920  		"input": githubv4.ClosePullRequestInput{
   921  			PullRequestID: pr.ID,
   922  		},
   923  	}
   924  
   925  	gql := graphQLClient(client.http, repo.RepoHost())
   926  	err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
   927  
   928  	return err
   929  }
   930  
   931  func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
   932  	var mutation struct {
   933  		ReopenPullRequest struct {
   934  			PullRequest struct {
   935  				ID githubv4.ID
   936  			}
   937  		} `graphql:"reopenPullRequest(input: $input)"`
   938  	}
   939  
   940  	variables := map[string]interface{}{
   941  		"input": githubv4.ReopenPullRequestInput{
   942  			PullRequestID: pr.ID,
   943  		},
   944  	}
   945  
   946  	gql := graphQLClient(client.http, repo.RepoHost())
   947  	err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
   948  
   949  	return err
   950  }
   951  
   952  func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
   953  	var mutation struct {
   954  		MarkPullRequestReadyForReview struct {
   955  			PullRequest struct {
   956  				ID githubv4.ID
   957  			}
   958  		} `graphql:"markPullRequestReadyForReview(input: $input)"`
   959  	}
   960  
   961  	variables := map[string]interface{}{
   962  		"input": githubv4.MarkPullRequestReadyForReviewInput{
   963  			PullRequestID: pr.ID,
   964  		},
   965  	}
   966  
   967  	gql := graphQLClient(client.http, repo.RepoHost())
   968  	return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables)
   969  }
   970  
   971  func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
   972  	path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
   973  	return client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
   974  }
   975  
   976  func min(a, b int) int {
   977  	if a < b {
   978  		return a
   979  	}
   980  	return b
   981  }