diff --git a/api/export_pr.go b/api/export_pr.go new file mode 100644 index 000000000..c6fc3db05 --- /dev/null +++ b/api/export_pr.go @@ -0,0 +1,107 @@ +package api + +import ( + "reflect" + "strings" +) + +func (issue *Issue) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(issue).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "milestone": + if issue.Milestone.Title != "" { + data[f] = &issue.Milestone + } else { + data[f] = nil + } + case "comments": + data[f] = issue.Comments.Nodes + case "assignees": + data[f] = issue.Assignees.Nodes + case "labels": + data[f] = issue.Labels.Nodes + case "projectCards": + data[f] = issue.ProjectCards.Nodes + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + + return &data +} + +func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(pr).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "headRepository": + data[f] = map[string]string{"name": pr.HeadRepository.Name} + case "milestone": + if pr.Milestone.Title != "" { + data[f] = &pr.Milestone + } else { + data[f] = nil + } + case "statusCheckRollup": + if n := pr.Commits.Nodes; len(n) > 0 { + data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes + } else { + data[f] = nil + } + case "comments": + data[f] = pr.Comments.Nodes + case "assignees": + data[f] = pr.Assignees.Nodes + case "labels": + data[f] = pr.Labels.Nodes + case "projectCards": + data[f] = pr.ProjectCards.Nodes + case "reviews": + data[f] = pr.Reviews.Nodes + case "files": + data[f] = pr.Files.Nodes + case "reviewRequests": + requests := make([]interface{}, 0, len(pr.ReviewRequests.Nodes)) + for _, req := range pr.ReviewRequests.Nodes { + if req.RequestedReviewer.TypeName == "" { + continue + } + requests = append(requests, req.RequestedReviewer) + } + data[f] = &requests + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + + return &data +} + +func ExportIssues(issues []Issue, fields []string) *[]interface{} { + data := make([]interface{}, len(issues)) + for i, issue := range issues { + data[i] = issue.ExportData(fields) + } + return &data +} + +func ExportPRs(prs []PullRequest, fields []string) *[]interface{} { + data := make([]interface{}, len(prs)) + for i, pr := range prs { + data[i] = pr.ExportData(fields) + } + return &data +} + +func fieldByName(v reflect.Value, field string) reflect.Value { + return v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(field, s) + }) +} diff --git a/api/export_pr_test.go b/api/export_pr_test.go new file mode 100644 index 000000000..9fd0ba848 --- /dev/null +++ b/api/export_pr_test.go @@ -0,0 +1,148 @@ +package api + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssue_ExportData(t *testing.T) { + tests := []struct { + name string + fields []string + inputJSON string + outputJSON string + }{ + { + name: "simple", + fields: []string{"number", "title"}, + inputJSON: heredoc.Doc(` + { "title": "Bugs hugs", "number": 2345 } + `), + outputJSON: heredoc.Doc(` + { + "number": 2345, + "title": "Bugs hugs" + } + `), + }, + { + name: "project cards", + fields: []string{"projectCards"}, + inputJSON: heredoc.Doc(` + { "projectCards": { "nodes": [ + { + "project": { "name": "Rewrite" }, + "column": { "name": "TO DO" } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectCards": [ + { + "project": { + "name": "Rewrite" + }, + "column": { + "name": "TO DO" + } + } + ] + } + `), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var issue Issue + dec := json.NewDecoder(strings.NewReader(tt.inputJSON)) + require.NoError(t, dec.Decode(&issue)) + + exported := issue.ExportData(tt.fields) + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetIndent("", "\t") + require.NoError(t, enc.Encode(exported)) + assert.Equal(t, tt.outputJSON, buf.String()) + }) + } +} + +func TestPullRequest_ExportData(t *testing.T) { + tests := []struct { + name string + fields []string + inputJSON string + outputJSON string + }{ + { + name: "simple", + fields: []string{"number", "title"}, + inputJSON: heredoc.Doc(` + { "title": "Bugs hugs", "number": 2345 } + `), + outputJSON: heredoc.Doc(` + { + "number": 2345, + "title": "Bugs hugs" + } + `), + }, + { + name: "status checks", + fields: []string{"statusCheckRollup"}, + inputJSON: heredoc.Doc(` + { "commits": { "nodes": [ + { "commit": { "statusCheckRollup": { "contexts": { "nodes": [ + { + "__typename": "CheckRun", + "name": "mycheck", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "startedAt": "2020-08-31T15:44:24+02:00", + "completedAt": "2020-08-31T15:45:24+02:00", + "detailsUrl": "http://example.com/details" + } + ] } } } } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "statusCheckRollup": [ + { + "__typename": "CheckRun", + "name": "mycheck", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "startedAt": "2020-08-31T15:44:24+02:00", + "completedAt": "2020-08-31T15:45:24+02:00", + "detailsUrl": "http://example.com/details" + } + ] + } + `), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var pr PullRequest + dec := json.NewDecoder(strings.NewReader(tt.inputJSON)) + require.NoError(t, dec.Decode(&pr)) + + exported := pr.ExportData(tt.fields) + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetIndent("", "\t") + require.NoError(t, enc.Encode(exported)) + assert.Equal(t, tt.outputJSON, buf.String()) + }) + } +} diff --git a/api/queries_comments.go b/api/queries_comments.go index db7482692..db6ad25e7 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -16,14 +16,14 @@ type Comments struct { } type Comment struct { - Author Author - AuthorAssociation string - Body string - CreatedAt time.Time - IncludesCreatedEdit bool - IsMinimized bool - MinimizedReason string - ReactionGroups ReactionGroups + Author Author `json:"author"` + AuthorAssociation string `json:"authorAssociation"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + IncludesCreatedEdit bool `json:"includesCreatedEdit"` + IsMinimized bool `json:"isMinimized"` + MinimizedReason string `json:"minimizedReason"` + ReactionGroups ReactionGroups `json:"reactionGroups"` } type PageInfo struct { diff --git a/api/queries_issue.go b/api/queries_issue.go index 55ec25ef7..7804a7613 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -2,10 +2,7 @@ package api import ( "context" - "encoding/base64" "fmt" - "strconv" - "strings" "time" "github.com/cli/cli/internal/ghrepo" @@ -33,6 +30,7 @@ type Issue struct { Body string CreatedAt time.Time UpdatedAt time.Time + ClosedAt *time.Time Comments Comments Author Author Assignees Assignees @@ -44,7 +42,7 @@ type Issue struct { type Assignees struct { Nodes []struct { - Login string + Login string `json:"login"` } TotalCount int } @@ -59,7 +57,7 @@ func (a Assignees) Logins() []string { type Labels struct { Nodes []struct { - Name string + Name string `json:"name"` } TotalCount int } @@ -75,11 +73,11 @@ func (l Labels) Names() []string { type ProjectCards struct { Nodes []struct { Project struct { - Name string - } + Name string `json:"name"` + } `json:"project"` Column struct { - Name string - } + Name string `json:"name"` + } `json:"column"` } TotalCount int } @@ -93,7 +91,7 @@ func (p ProjectCards) ProjectNames() []string { } type Milestone struct { - Title string + Title string `json:"title"` } type IssuesDisabledError struct { @@ -101,25 +99,9 @@ type IssuesDisabledError struct { } type Author struct { - Login string + Login string `json:"login"` } -const fragments = ` - fragment issue on Issue { - number - title - url - state - updatedAt - labels(first: 100) { - nodes { - name - } - totalCount - } - } -` - // IssueCreate creates an issue in a GitHub repository func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) { query := ` @@ -155,7 +137,12 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } -func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { +type IssueStatusOptions struct { + Username string + Fields []string +} + +func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptions) (*IssuesPayload, error) { type response struct { Repository struct { Assigned struct { @@ -174,6 +161,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) } } + fragments := fmt.Sprintf("fragment issue on Issue{%s}", PullRequestGraphQL(options.Fields)) query := fragments + ` query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { @@ -202,7 +190,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) variables := map[string]interface{}{ "owner": repo.RepoOwner(), "repo": repo.RepoName(), - "viewer": currentUsername, + "viewer": options.Username, } var resp response @@ -233,123 +221,6 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) return &payload, nil } -func IssueList(client *Client, repo ghrepo.Interface, state string, assigneeString string, limit int, authorString string, mentionString string, milestoneString string) (*IssuesAndTotalCount, error) { - var states []string - switch state { - case "open", "": - states = []string{"OPEN"} - case "closed": - states = []string{"CLOSED"} - case "all": - states = []string{"OPEN", "CLOSED"} - default: - return nil, fmt.Errorf("invalid state: %s", state) - } - - query := fragments + ` - query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String, $milestone: String) { - repository(owner: $owner, name: $repo) { - hasIssuesEnabled - issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention, milestone: $milestone}) { - totalCount - nodes { - ...issue - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - ` - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "states": states, - } - if assigneeString != "" { - variables["assignee"] = assigneeString - } - if authorString != "" { - variables["author"] = authorString - } - if mentionString != "" { - variables["mention"] = mentionString - } - - if milestoneString != "" { - var milestone *RepoMilestone - if milestoneNumber, err := strconv.ParseInt(milestoneString, 10, 32); err == nil { - milestone, err = MilestoneByNumber(client, repo, int32(milestoneNumber)) - if err != nil { - return nil, err - } - } else { - milestone, err = MilestoneByTitle(client, repo, "all", milestoneString) - if err != nil { - return nil, err - } - } - - milestoneRESTID, err := milestoneNodeIdToDatabaseId(milestone.ID) - if err != nil { - return nil, err - } - variables["milestone"] = milestoneRESTID - } - - type responseData struct { - Repository struct { - Issues struct { - TotalCount int - Nodes []Issue - PageInfo struct { - HasNextPage bool - EndCursor string - } - } - HasIssuesEnabled bool - } - } - - var issues []Issue - var totalCount int - pageLimit := min(limit, 100) - -loop: - for { - var response responseData - variables["limit"] = pageLimit - err := client.GraphQL(repo.RepoHost(), query, variables, &response) - if err != nil { - return nil, err - } - if !response.Repository.HasIssuesEnabled { - return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) - } - totalCount = response.Repository.Issues.TotalCount - - for _, issue := range response.Repository.Issues.Nodes { - issues = append(issues, issue) - if len(issues) == limit { - break loop - } - } - - if response.Repository.Issues.PageInfo.HasNextPage { - variables["endCursor"] = response.Repository.Issues.PageInfo.EndCursor - pageLimit = min(pageLimit, limit-len(issues)) - } else { - break - } - } - - res := IssuesAndTotalCount{Issues: issues, TotalCount: totalCount} - return &res, nil -} - func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) { type response struct { Repository struct { @@ -450,80 +321,6 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e return &resp.Repository.Issue, nil } -func IssueSearch(client *Client, repo ghrepo.Interface, searchQuery string, limit int) (*IssuesAndTotalCount, error) { - query := fragments + - `query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { - repository(name: $repo, owner: $owner) { - hasIssuesEnabled - } - search(type: $type, last: $limit, after: $after, query: $query) { - issueCount - nodes { ...issue } - pageInfo { - hasNextPage - endCursor - } - } - }` - - type response struct { - Repository struct { - HasIssuesEnabled bool - } - Search struct { - IssueCount int - Nodes []Issue - PageInfo struct { - HasNextPage bool - EndCursor string - } - } - } - - perPage := min(limit, 100) - searchQuery = fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery) - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "type": "ISSUE", - "limit": perPage, - "query": searchQuery, - } - - ic := IssuesAndTotalCount{} - -loop: - for { - var resp response - err := client.GraphQL(repo.RepoHost(), query, variables, &resp) - if err != nil { - return nil, err - } - - if !resp.Repository.HasIssuesEnabled { - return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) - } - - ic.TotalCount = resp.Search.IssueCount - - for _, issue := range resp.Search.Nodes { - ic.Issues = append(ic.Issues, issue) - if len(ic.Issues) == limit { - break loop - } - } - - if !resp.Search.PageInfo.HasNextPage { - break - } - variables["after"] = resp.Search.PageInfo.EndCursor - variables["perPage"] = min(perPage, limit-len(ic.Issues)) - } - - return &ic, nil -} - func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { var mutation struct { CloseIssue struct { @@ -605,23 +402,6 @@ func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIs return err } -// milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID -// This conversion is necessary since the GraphQL API requires the use of the milestone's database ID -// for querying the related issues. -func milestoneNodeIdToDatabaseId(nodeId string) (string, error) { - // The Node ID is Base64 obfuscated, with an underlying pattern: - // "09:Milestone12345", where "12345" is the database ID - decoded, err := base64.StdEncoding.DecodeString(nodeId) - if err != nil { - return "", err - } - splitted := strings.Split(string(decoded), "Milestone") - if len(splitted) != 2 { - return "", fmt.Errorf("couldn't get database id from node id") - } - return splitted[1], nil -} - func (i Issue) Link() string { return i.URL } diff --git a/api/queries_pr.go b/api/queries_pr.go index 9b4661e37..29d1a0470 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/set" "github.com/shurcooL/githubv4" "golang.org/x/sync/errgroup" ) @@ -41,13 +42,24 @@ type PullRequest struct { Mergeable string Additions int Deletions int + ChangedFiles int MergeStateStatus string + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt *time.Time + MergedAt *time.Time - Author struct { - Login string + MergeCommit *Commit + PotentialMergeCommit *Commit + + Files struct { + Nodes []PullRequestFile } + + Author Author + MergedBy *Author HeadRepositoryOwner struct { - Login string + Login string `json:"login"` } HeadRepository struct { Name string @@ -75,15 +87,16 @@ type PullRequest struct { StatusCheckRollup struct { Contexts struct { Nodes []struct { - Name string - Context string - State string - Status string - Conclusion string - StartedAt time.Time - CompletedAt time.Time - DetailsURL string - TargetURL string + TypeName string `json:"__typename"` + Name string `json:"name"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + DetailsURL string `json:"detailsUrl"` + TargetURL string `json:"targetUrl,omitempty"` } } } @@ -100,12 +113,22 @@ type PullRequest struct { ReviewRequests ReviewRequests } +type Commit struct { + OID string `json:"oid"` +} + +type PullRequestFile struct { + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + type ReviewRequests struct { Nodes []struct { RequestedReviewer struct { TypeName string `json:"__typename"` - Login string - Name string + Login string `json:"login"` + Name string `json:"name"` } } TotalCount int @@ -304,7 +327,14 @@ func determinePullRequestFeatures(httpClient *http.Client, hostname string) (prF return } -func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) { +type StatusOptions struct { + CurrentPR int + HeadRef string + Username string + Fields []string +} + +func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOptions) (*PullRequestsPayload, error) { type edges struct { TotalCount int Edges []struct { @@ -324,12 +354,148 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu ReviewRequested edges } - cachedClient := NewCachedClient(client.http, time.Hour*24) - prFeatures, err := determinePullRequestFeatures(cachedClient, repo.RepoHost()) + var fragments string + if len(options.Fields) > 0 { + fields := set.NewStringSet() + fields.AddValues(options.Fields) + // these are always necessary to find the PR for the current branch + fields.AddValues([]string{"isCrossRepository", "headRepositoryOwner", "headRefName"}) + gr := PullRequestGraphQL(fields.ToSlice()) + fragments = fmt.Sprintf("fragment pr on PullRequest{%[1]s}fragment prWithReviews on PullRequest{%[1]s}", gr) + } else { + var err error + fragments, err = pullRequestFragment(client.http, repo.RepoHost()) + if err != nil { + return nil, err + } + } + + queryPrefix := ` + query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) { + totalCount + edges { + node { + ...prWithReviews + } + } + } + } + ` + if options.CurrentPR > 0 { + queryPrefix = ` + query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + pullRequest(number: $number) { + ...prWithReviews + } + } + ` + } + + query := fragments + queryPrefix + ` + viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) { + totalCount: issueCount + edges { + node { + ...prWithReviews + } + } + } + reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) { + totalCount: issueCount + edges { + node { + ...pr + } + } + } + } + ` + + currentUsername := options.Username + if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) { + var err error + currentUsername, err = CurrentLoginName(client, repo.RepoHost()) + if err != nil { + return nil, err + } + } + + viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername) + reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername) + + currentPRHeadRef := options.HeadRef + branchWithoutOwner := currentPRHeadRef + if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 { + branchWithoutOwner = currentPRHeadRef[idx+1:] + } + + variables := map[string]interface{}{ + "viewerQuery": viewerQuery, + "reviewerQuery": reviewerQuery, + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "headRefName": branchWithoutOwner, + "number": options.CurrentPR, + } + + var resp response + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } + var viewerCreated []PullRequest + for _, edge := range resp.ViewerCreated.Edges { + viewerCreated = append(viewerCreated, edge.Node) + } + + var reviewRequested []PullRequest + for _, edge := range resp.ReviewRequested.Edges { + reviewRequested = append(reviewRequested, edge.Node) + } + + var currentPR = resp.Repository.PullRequest + if currentPR == nil { + for _, edge := range resp.Repository.PullRequests.Edges { + if edge.Node.HeadLabel() == currentPRHeadRef { + currentPR = &edge.Node + break // Take the most recent PR for the current branch + } + } + } + + payload := PullRequestsPayload{ + ViewerCreated: PullRequestAndTotalCount{ + PullRequests: viewerCreated, + TotalCount: resp.ViewerCreated.TotalCount, + }, + ReviewRequested: PullRequestAndTotalCount{ + PullRequests: reviewRequested, + TotalCount: resp.ReviewRequested.TotalCount, + }, + CurrentPR: currentPR, + DefaultBranch: resp.Repository.DefaultBranchRef.Name, + } + + return &payload, nil +} + +func pullRequestFragment(httpClient *http.Client, hostname string) (string, error) { + cachedClient := NewCachedClient(httpClient, time.Hour*24) + prFeatures, err := determinePullRequestFeatures(cachedClient, hostname) + if err != nil { + return "", err + } + var reviewsFragment string if prFeatures.HasReviewDecision { reviewsFragment = "reviewDecision" @@ -391,121 +557,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu %s } `, requiresStrictStatusChecks, statusesFragment, reviewsFragment) - - queryPrefix := ` - query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { - repository(owner: $owner, name: $repo) { - defaultBranchRef { - name - } - pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) { - totalCount - edges { - node { - ...prWithReviews - } - } - } - } - ` - if currentPRNumber > 0 { - queryPrefix = ` - query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { - repository(owner: $owner, name: $repo) { - defaultBranchRef { - name - } - pullRequest(number: $number) { - ...prWithReviews - } - } - ` - } - - query := fragments + queryPrefix + ` - viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) { - totalCount: issueCount - edges { - node { - ...prWithReviews - } - } - } - reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) { - totalCount: issueCount - edges { - node { - ...pr - } - } - } - } - ` - - if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) { - currentUsername, err = CurrentLoginName(client, repo.RepoHost()) - if err != nil { - return nil, err - } - } - - viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername) - reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername) - - branchWithoutOwner := currentPRHeadRef - if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 { - branchWithoutOwner = currentPRHeadRef[idx+1:] - } - - variables := map[string]interface{}{ - "viewerQuery": viewerQuery, - "reviewerQuery": reviewerQuery, - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "headRefName": branchWithoutOwner, - "number": currentPRNumber, - } - - var resp response - err = client.GraphQL(repo.RepoHost(), query, variables, &resp) - if err != nil { - return nil, err - } - - var viewerCreated []PullRequest - for _, edge := range resp.ViewerCreated.Edges { - viewerCreated = append(viewerCreated, edge.Node) - } - - var reviewRequested []PullRequest - for _, edge := range resp.ReviewRequested.Edges { - reviewRequested = append(reviewRequested, edge.Node) - } - - var currentPR = resp.Repository.PullRequest - if currentPR == nil { - for _, edge := range resp.Repository.PullRequests.Edges { - if edge.Node.HeadLabel() == currentPRHeadRef { - currentPR = &edge.Node - break // Take the most recent PR for the current branch - } - } - } - - payload := PullRequestsPayload{ - ViewerCreated: PullRequestAndTotalCount{ - PullRequests: viewerCreated, - TotalCount: resp.ViewerCreated.TotalCount, - }, - ReviewRequested: PullRequestAndTotalCount{ - PullRequests: reviewRequested, - TotalCount: resp.ReviewRequested.TotalCount, - }, - CurrentPR: currentPR, - DefaultBranch: resp.Repository.DefaultBranchRef.Name, - } - - return &payload, nil + return fragments, nil } func prCommitsFragment(httpClient *http.Client, hostname string) (string, error) { @@ -972,10 +1024,3 @@ func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) er path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) return client.REST(repo.RepoHost(), "DELETE", path, nil, nil) } - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 27365429e..030472d75 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -28,14 +28,14 @@ type PullRequestReviews struct { } type PullRequestReview struct { - Author Author - AuthorAssociation string - Body string - CreatedAt time.Time - IncludesCreatedEdit bool - ReactionGroups ReactionGroups - State string - URL string + Author Author `json:"author"` + AuthorAssociation string `json:"authorAssociation"` + Body string `json:"body"` + SubmittedAt *time.Time `json:"submittedAt"` + IncludesCreatedEdit bool `json:"includesCreatedEdit"` + ReactionGroups ReactionGroups `json:"reactionGroups"` + State string `json:"state"` + URL string `json:"url,omitempty"` } func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { @@ -115,7 +115,10 @@ func (prr PullRequestReview) Content() string { } func (prr PullRequestReview) Created() time.Time { - return prr.CreatedAt + if prr.SubmittedAt == nil { + return time.Time{} + } + return *prr.SubmittedAt } func (prr PullRequestReview) HiddenReason() string { diff --git a/api/query_builder.go b/api/query_builder.go new file mode 100644 index 000000000..84d9d8f91 --- /dev/null +++ b/api/query_builder.go @@ -0,0 +1,188 @@ +package api + +import ( + "strings" +) + +func squeeze(r rune) rune { + switch r { + case '\n', '\t': + return -1 + default: + return r + } +} + +func shortenQuery(q string) string { + return strings.Map(squeeze, q) +} + +var issueComments = shortenQuery(` + comments(last: 100) { + nodes { + author{login}, + authorAssociation, + body, + createdAt, + includesCreatedEdit, + isMinimized, + minimizedReason, + reactionGroups{content,users{totalCount}} + }, + totalCount + } +`) + +var prReviewRequests = shortenQuery(` + reviewRequests(last: 100) { + nodes { + requestedReviewer { + __typename, + ...on User{login}, + ...on Team{name} + } + }, + totalCount + } +`) + +var prReviews = shortenQuery(` + reviews(last: 100) { + nodes { + author{login}, + authorAssociation, + submittedAt, + body, + state, + reactionGroups{content,users{totalCount}} + } + } +`) + +var prFiles = shortenQuery(` + files(first: 100) { + nodes { + additions, + deletions, + path + } + } +`) + +var prStatusCheckRollup = shortenQuery(` + commits(last: 1) { + totalCount, + nodes { + commit { + oid, + statusCheckRollup { + contexts(last: 100) { + nodes { + __typename + ...on StatusContext { + context, + state, + targetUrl + }, + ...on CheckRun { + name, + status, + conclusion, + startedAt, + completedAt, + detailsUrl + } + } + } + } + } + } + } +`) + +var IssueFields = []string{ + "assignees", + "author", + "body", + "closed", + "comments", + "createdAt", + "closedAt", + "id", + "labels", + "milestone", + "number", + "projectCards", + "reactionGroups", + "state", + "title", + "updatedAt", + "url", +} + +var PullRequestFields = append(IssueFields, + "additions", + "baseRefName", + "changedFiles", + "deletions", + "files", + "headRefName", + "headRepository", + "headRepositoryOwner", + "isCrossRepository", + "isDraft", + "maintainerCanModify", + "mergeable", + "mergeCommit", + "mergedAt", + "mergedBy", + "mergeStateStatus", + "potentialMergeCommit", + "reviewDecision", + "reviewRequests", + "reviews", + "statusCheckRollup", +) + +func PullRequestGraphQL(fields []string) string { + var q []string + for _, field := range fields { + switch field { + case "author": + q = append(q, `author{login}`) + case "mergedBy": + q = append(q, `mergedBy{login}`) + case "headRepositoryOwner": + q = append(q, `headRepositoryOwner{login}`) + case "headRepository": + q = append(q, `headRepository{name}`) + case "assignees": + q = append(q, `assignees(first:100){nodes{login},totalCount}`) + case "labels": + q = append(q, `labels(first:100){nodes{name},totalCount}`) + case "projectCards": + q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`) + case "milestone": + q = append(q, `milestone{title}`) + case "reactionGroups": + q = append(q, `reactionGroups{content,users{totalCount}}`) + case "mergeCommit": + q = append(q, `mergeCommit{oid}`) + case "potentialMergeCommit": + q = append(q, `potentialMergeCommit{oid}`) + case "comments": + q = append(q, issueComments) + case "reviewRequests": + q = append(q, prReviewRequests) + case "reviews": + q = append(q, prReviews) + case "files": + q = append(q, prFiles) + case "statusCheckRollup": + q = append(q, prStatusCheckRollup) + default: + q = append(q, field) + } + } + return strings.Join(q, ",") +} diff --git a/api/query_builder_test.go b/api/query_builder_test.go new file mode 100644 index 000000000..e8d48a10e --- /dev/null +++ b/api/query_builder_test.go @@ -0,0 +1,39 @@ +package api + +import "testing" + +func TestPullRequestGraphQL(t *testing.T) { + tests := []struct { + name string + fields []string + want string + }{ + { + name: "empty", + fields: []string(nil), + want: "", + }, + { + name: "simple fields", + fields: []string{"number", "title"}, + want: "number,title", + }, + { + name: "fields with nested structures", + fields: []string{"author", "assignees"}, + want: "author{login},assignees(first:100){nodes{login},totalCount}", + }, + { + name: "compressed query", + fields: []string{"files"}, + want: "files(first: 100) {nodes {additions,deletions,path}}", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PullRequestGraphQL(tt.fields); got != tt.want { + t.Errorf("PullRequestGraphQL() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/reaction_groups.go b/api/reaction_groups.go index 849fe4b36..769edc6aa 100644 --- a/api/reaction_groups.go +++ b/api/reaction_groups.go @@ -1,14 +1,42 @@ package api +import ( + "bytes" + "encoding/json" +) + type ReactionGroups []ReactionGroup +func (rg ReactionGroups) MarshalJSON() ([]byte, error) { + buf := bytes.Buffer{} + buf.WriteRune('[') + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + + hasPrev := false + for _, g := range rg { + if g.Users.TotalCount == 0 { + continue + } + if hasPrev { + buf.WriteRune(',') + } + if err := encoder.Encode(&g); err != nil { + return nil, err + } + hasPrev = true + } + buf.WriteRune(']') + return buf.Bytes(), nil +} + type ReactionGroup struct { - Content string - Users ReactionGroupUsers + Content string `json:"content"` + Users ReactionGroupUsers `json:"users"` } type ReactionGroupUsers struct { - TotalCount int + TotalCount int `json:"totalCount"` } func (rg ReactionGroup) Count() int { diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 714f555cb..249b5a30c 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -22,6 +22,7 @@ import ( "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/export" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/jsoncolor" "github.com/spf13/cobra" @@ -100,22 +101,6 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command there are no more pages of results. For GraphQL requests, this requires that the original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the %[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection. - - The %[1]s--jq%[1]s option accepts a query in jq syntax and will print only the resulting - values that match the query. This is equivalent to piping the output to %[1]sjq -r%[1]s, - but does not require the jq utility to be installed on the system. To learn more - about the query syntax, see: https://stedolan.github.io/jq/manual/v1.6/ - - With %[1]s--template%[1]s, the provided Go template is rendered using the JSON data as input. - For the syntax of Go templates, see: https://golang.org/pkg/text/template/ - - The following functions are available in templates: - - %[1]scolor