cli/pkg/cmd/issue/shared/lookup.go
Heath Stewart 17679cf65f
Edit multiple issues, PRs in parallel (#7259)
Allows multiple issues or PRs to be edited in parallel, and querying for shared fields once to reduce network requests.

Co-authored-by: Sam Coe <samcoe@users.noreply.github.com>
2023-04-25 14:45:41 +02:00

230 lines
6 KiB
Go

package shared
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/cli/v2/api"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/set"
"golang.org/x/sync/errgroup"
)
// IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields
// could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError.
func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) {
issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
if err != nil {
return nil, nil, err
}
if baseRepo == nil {
var err error
if baseRepo, err = baseRepoFn(); err != nil {
return nil, nil, err
}
}
issue, err := findIssueOrPR(httpClient, baseRepo, issueNumber, fields)
return issue, baseRepo, err
}
// IssuesFromArgWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields
// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError.
func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) {
var issuesRepo ghrepo.Interface
issueNumbers := make([]int, 0, len(args))
for _, arg := range args {
issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
if err != nil {
return nil, nil, err
}
issueNumbers = append(issueNumbers, issueNumber)
if baseRepo == nil {
var err error
if baseRepo, err = baseRepoFn(); err != nil {
return nil, nil, err
}
}
if issuesRepo == nil {
issuesRepo = baseRepo
continue
}
if !ghrepo.IsSame(issuesRepo, baseRepo) {
return nil, nil, fmt.Errorf(
"multiple issues must be in same repo: found %q, expected %q",
ghrepo.FullName(baseRepo),
ghrepo.FullName(issuesRepo),
)
}
}
issuesChan := make(chan *api.Issue, len(args))
g := errgroup.Group{}
for _, num := range issueNumbers {
issueNumber := num
g.Go(func() error {
issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields)
if err != nil {
return err
}
issuesChan <- issue
return nil
})
}
err := g.Wait()
close(issuesChan)
if err != nil {
return nil, nil, err
}
issues := make([]*api.Issue, 0, len(args))
for issue := range issuesChan {
issues = append(issues, issue)
}
return issues, issuesRepo, nil
}
var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`)
func issueMetadataFromURL(s string) (int, ghrepo.Interface) {
u, err := url.Parse(s)
if err != nil {
return 0, nil
}
if u.Scheme != "https" && u.Scheme != "http" {
return 0, nil
}
m := issueURLRE.FindStringSubmatch(u.Path)
if m == nil {
return 0, nil
}
repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
issueNumber, _ := strconv.Atoi(m[3])
return issueNumber, repo
}
// Returns the issue number and repo if the issue URL is provided.
// If only the issue number is provided, returns the number and nil repo.
func IssueNumberAndRepoFromArg(arg string) (int, ghrepo.Interface, error) {
issueNumber, baseRepo := issueMetadataFromURL(arg)
if issueNumber == 0 {
var err error
issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#"))
if err != nil {
return 0, nil, fmt.Errorf("invalid issue format: %q", arg)
}
}
return issueNumber, baseRepo, nil
}
type PartialLoadError struct {
error
}
func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) {
fieldSet := set.NewStringSet()
fieldSet.AddValues(fields)
if fieldSet.Contains("stateReason") {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
detector := fd.NewDetector(cachedClient, repo.RepoHost())
features, err := detector.IssueFeatures()
if err != nil {
return nil, err
}
if !features.StateReason {
fieldSet.Remove("stateReason")
}
}
var getProjectItems bool
if fieldSet.Contains("projectItems") {
getProjectItems = true
fieldSet.Remove("projectItems")
fieldSet.Add("number")
}
fields = fieldSet.ToSlice()
type response struct {
Repository struct {
HasIssuesEnabled bool
Issue *api.Issue
}
}
query := fmt.Sprintf(`
query IssueByNumber($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
hasIssuesEnabled
issue: issueOrPullRequest(number: $number) {
__typename
...on Issue{%[1]s}
...on PullRequest{%[2]s}
}
}
}`, api.IssueGraphQL(fields), api.PullRequestGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"number": number,
}
var resp response
client := api.NewClientFromHTTP(httpClient)
if err := client.GraphQL(repo.RepoHost(), query, variables, &resp); err != nil {
var gerr api.GraphQLError
if errors.As(err, &gerr) {
if gerr.Match("NOT_FOUND", "repository.issue") && !resp.Repository.HasIssuesEnabled {
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
} else if gerr.Match("FORBIDDEN", "repository.issue.projectCards.") {
issue := resp.Repository.Issue
// remove nil entries for project cards due to permission issues
projects := make([]*api.ProjectInfo, 0, len(issue.ProjectCards.Nodes))
for _, p := range issue.ProjectCards.Nodes {
if p != nil {
projects = append(projects, p)
}
}
issue.ProjectCards.Nodes = projects
return issue, &PartialLoadError{err}
}
}
return nil, err
}
if resp.Repository.Issue == nil {
return nil, errors.New("issue was not found but GraphQL reported no error")
}
if getProjectItems {
apiClient := api.NewClientFromHTTP(httpClient)
err := api.ProjectsV2ItemsForIssue(apiClient, repo, resp.Repository.Issue)
if err != nil && !api.ProjectsV2IgnorableError(err) {
return nil, err
}
}
return resp.Repository.Issue, nil
}