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>
230 lines
6 KiB
Go
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
|
|
}
|