diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 56f1248d6..d339a0685 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -36,6 +36,8 @@ Run the new binary as: Run tests with: `go test ./...` +See [project layout documentation](../project-layout.md) for information on where to find specific source files. + ## Submitting a pull request 1. Create a new branch: `git checkout -b my-branch-name` diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..aa6662d49 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,4 @@ + diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md deleted file mode 100644 index ca33dd34c..000000000 --- a/.github/PULL_REQUEST_TEMPLATE/bug_fix.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: "\U0001F41B Bug fix" -about: Fix a bug in GitHub CLI - ---- - - - -## Summary - -closes #[issue number] - -## Details - -- diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index ccb42c8df..7066ef561 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -80,7 +80,7 @@ jobs: popd - name: Run reprepro env: - RELEASES: "cosmic eoan disco groovy focal stable oldstable testing unstable buster bullseye stretch jessie bionic trusty precise xenial" + RELEASES: "cosmic eoan disco groovy focal stable oldstable testing unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling" run: | mkdir -p upload for release in $RELEASES; do @@ -133,9 +133,11 @@ jobs: - name: Build MSI id: buildmsi shell: bash + env: + ZIP_FILE: ${{ steps.download_exe.outputs.zip }} run: | mkdir -p build - msi="$(basename "${{ steps.download_exe.outputs.zip }}" ".zip").msi" + msi="$(basename "$ZIP_FILE" ".zip").msi" printf "::set-output name=msi::%s\n" "$msi" go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}" - name: Obtain signing cert @@ -145,14 +147,24 @@ jobs: run: .\script\setup-windows-certificate.ps1 - name: Sign MSI env: + CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }} + EXE_FILE: ${{ steps.buildmsi.outputs.msi }} GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }} - run: | - .\script\sign.ps1 -Certificate "${{ steps.obtain_cert.outputs.cert-file }}" ` - -Executable "${{ steps.buildmsi.outputs.msi }}" + run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $env:EXE_FILE - name: Upload MSI shell: bash - run: hub release edit "${GITHUB_REF#refs/tags/}" -m "" --draft=false -a "${{ steps.buildmsi.outputs.msi }}" + run: | + tag_name="${GITHUB_REF#refs/tags/}" + hub release edit "$tag_name" -m "" -a "$MSI_FILE" + release_url="$(gh api repos/:owner/:repo/releases -q ".[]|select(.tag_name==\"${tag_name}\")|.url")" + publish_args=( -F draft=false ) + if [[ $GITHUB_REF != *-* ]]; then + publish_args+=( -f discussion_category_name="$DISCUSSION_CATEGORY" ) + fi + gh api -X PATCH "$release_url" "${publish_args[@]}" env: + MSI_FILE: ${{ steps.buildmsi.outputs.msi }} + DISCUSSION_CATEGORY: General GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - name: Bump homebrew-core formula uses: mislav/bump-homebrew-formula-action@v1 diff --git a/api/export_pr.go b/api/export_pr.go index dc70ee4fa..4bd0aabc7 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -6,16 +6,11 @@ import ( ) 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": @@ -25,7 +20,6 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} { case "projectCards": data[f] = issue.ProjectCards.Nodes default: - v := reflect.ValueOf(issue).Elem() sf := fieldByName(v, f) data[f] = sf.Interface() } @@ -35,24 +29,42 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} { } 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 - } + data[f] = pr.HeadRepository case "statusCheckRollup": - if n := pr.Commits.Nodes; len(n) > 0 { + if n := pr.StatusCheckRollup.Nodes; len(n) > 0 { data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes } else { data[f] = nil } + case "commits": + commits := make([]interface{}, 0, len(pr.Commits.Nodes)) + for _, c := range pr.Commits.Nodes { + commit := c.Commit + authors := make([]interface{}, 0, len(commit.Authors.Nodes)) + for _, author := range commit.Authors.Nodes { + authors = append(authors, map[string]interface{}{ + "name": author.Name, + "email": author.Email, + "id": author.User.ID, + "login": author.User.Login, + }) + } + commits = append(commits, map[string]interface{}{ + "oid": commit.OID, + "messageHeadline": commit.MessageHeadline, + "messageBody": commit.MessageBody, + "committedDate": commit.CommittedDate, + "authoredDate": commit.AuthoredDate, + "authors": authors, + }) + } + data[f] = commits case "comments": data[f] = pr.Comments.Nodes case "assignees": @@ -75,7 +87,6 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { } data[f] = &requests default: - v := reflect.ValueOf(pr).Elem() sf := fieldByName(v, f) data[f] = sf.Interface() } @@ -84,22 +95,6 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { return &data } -func ExportIssues(issues []Issue, fields []string) *[]interface{} { - data := make([]interface{}, len(issues)) - for i := range issues { - data[i] = issues[i].ExportData(fields) - } - return &data -} - -func ExportPRs(prs []PullRequest, fields []string) *[]interface{} { - data := make([]interface{}, len(prs)) - for i := range prs { - data[i] = prs[i].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 index eff3c157d..dde730884 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -40,7 +40,10 @@ func TestIssue_ExportData(t *testing.T) { outputJSON: heredoc.Doc(` { "milestone": { - "title": "The next big thing" + "number": 0, + "title": "The next big thing", + "description": "", + "dueOn": null }, "number": 2345 } @@ -90,31 +93,6 @@ func TestIssue_ExportData(t *testing.T) { } } -func TestExportIssues(t *testing.T) { - issues := []Issue{ - {Milestone: Milestone{Title: "hi"}}, - {}, - } - exported := ExportIssues(issues, []string{"milestone"}) - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - enc.SetIndent("", "\t") - require.NoError(t, enc.Encode(exported)) - assert.Equal(t, heredoc.Doc(` - [ - { - "milestone": { - "title": "hi" - } - }, - { - "milestone": null - } - ] - `), buf.String()) -} - func TestPullRequest_ExportData(t *testing.T) { tests := []struct { name string @@ -144,7 +122,10 @@ func TestPullRequest_ExportData(t *testing.T) { outputJSON: heredoc.Doc(` { "milestone": { - "title": "The next big thing" + "number": 0, + "title": "The next big thing", + "description": "", + "dueOn": null }, "number": 2345 } @@ -154,7 +135,7 @@ func TestPullRequest_ExportData(t *testing.T) { name: "status checks", fields: []string{"statusCheckRollup"}, inputJSON: heredoc.Doc(` - { "commits": { "nodes": [ + { "statusCheckRollup": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "nodes": [ { "__typename": "CheckRun", diff --git a/api/export_repo.go b/api/export_repo.go new file mode 100644 index 000000000..8d4e669ad --- /dev/null +++ b/api/export_repo.go @@ -0,0 +1,53 @@ +package api + +import ( + "reflect" +) + +func (repo *Repository) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(repo).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "parent": + data[f] = miniRepoExport(repo.Parent) + case "templateRepository": + data[f] = miniRepoExport(repo.TemplateRepository) + case "languages": + data[f] = repo.Languages.Edges + case "labels": + data[f] = repo.Labels.Nodes + case "assignableUsers": + data[f] = repo.AssignableUsers.Nodes + case "mentionableUsers": + data[f] = repo.MentionableUsers.Nodes + case "milestones": + data[f] = repo.Milestones.Nodes + case "projects": + data[f] = repo.Projects.Nodes + case "repositoryTopics": + var topics []RepositoryTopic + for _, n := range repo.RepositoryTopics.Nodes { + topics = append(topics, n.Topic) + } + data[f] = topics + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + + return &data +} + +func miniRepoExport(r *Repository) map[string]interface{} { + if r == nil { + return nil + } + return map[string]interface{}{ + "id": r.ID, + "name": r.Name, + "owner": r.Owner, + } +} diff --git a/api/pull_request_test.go b/api/pull_request_test.go index 9fb1d9e72..2e4fa73b1 100644 --- a/api/pull_request_test.go +++ b/api/pull_request_test.go @@ -10,7 +10,7 @@ import ( func TestPullRequest_ChecksStatus(t *testing.T) { pr := PullRequest{} payload := ` - { "commits": { "nodes": [{ "commit": { + { "statusCheckRollup": { "nodes": [{ "commit": { "statusCheckRollup": { "contexts": { "nodes": [ diff --git a/api/queries_comments.go b/api/queries_comments.go index db6ad25e7..999c39033 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -4,7 +4,6 @@ import ( "context" "time" - "github.com/cli/cli/internal/ghrepo" "github.com/shurcooL/githubv4" "github.com/shurcooL/graphql" ) @@ -12,7 +11,10 @@ import ( type Comments struct { Nodes []Comment TotalCount int - PageInfo PageInfo + PageInfo struct { + HasNextPage bool + EndCursor string + } } type Comment struct { @@ -26,83 +28,6 @@ type Comment struct { ReactionGroups ReactionGroups `json:"reactionGroups"` } -type PageInfo struct { - HasNextPage bool - EndCursor string -} - -func CommentsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) (*Comments, error) { - type response struct { - Repository struct { - Issue struct { - Comments Comments `graphql:"comments(first: 100, after: $endCursor)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "repo": githubv4.String(repo.RepoName()), - "number": githubv4.Int(issue.Number), - "endCursor": (*githubv4.String)(nil), - } - - gql := graphQLClient(client.http, repo.RepoHost()) - - var comments []Comment - for { - var query response - err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables) - if err != nil { - return nil, err - } - - comments = append(comments, query.Repository.Issue.Comments.Nodes...) - if !query.Repository.Issue.Comments.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(query.Repository.Issue.Comments.PageInfo.EndCursor) - } - - return &Comments{Nodes: comments, TotalCount: len(comments)}, nil -} - -func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*Comments, error) { - type response struct { - Repository struct { - PullRequest struct { - Comments Comments `graphql:"comments(first: 100, after: $endCursor)"` - } `graphql:"pullRequest(number: $number)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "repo": githubv4.String(repo.RepoName()), - "number": githubv4.Int(pr.Number), - "endCursor": (*githubv4.String)(nil), - } - - gql := graphQLClient(client.http, repo.RepoHost()) - - var comments []Comment - for { - var query response - err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables) - if err != nil { - return nil, err - } - - comments = append(comments, query.Repository.PullRequest.Comments.Nodes...) - if !query.Repository.PullRequest.Comments.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Comments.PageInfo.EndCursor) - } - - return &Comments{Nodes: comments, TotalCount: len(comments)}, nil -} - type CommentCreateInput struct { Body string SubjectId string @@ -135,24 +60,6 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) ( return mutation.AddComment.CommentEdge.Node.URL, nil } -func commentsFragment() string { - return `comments(last: 1) { - nodes { - author { - login - } - authorAssociation - body - createdAt - includesCreatedEdit - isMinimized - minimizedReason - ` + reactionGroupsFragment() + ` - } - totalCount - }` -} - func (c Comment) AuthorLogin() string { return c.Author.Login } diff --git a/api/queries_issue.go b/api/queries_issue.go index 2dfab5742..c67ad3bcc 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -36,14 +36,12 @@ type Issue struct { Assignees Assignees Labels Labels ProjectCards ProjectCards - Milestone Milestone + Milestone *Milestone ReactionGroups ReactionGroups } type Assignees struct { - Nodes []struct { - Login string `json:"login"` - } + Nodes []GitHubUser TotalCount int } @@ -56,9 +54,7 @@ func (a Assignees) Logins() []string { } type Labels struct { - Nodes []struct { - Name string `json:"name"` - } + Nodes []IssueLabel TotalCount int } @@ -91,14 +87,26 @@ func (p ProjectCards) ProjectNames() []string { } type Milestone struct { - Title string `json:"title"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + DueOn *time.Time `json:"dueOn"` } type IssuesDisabledError struct { error } +type Owner struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Login string `json:"login"` +} + type Author struct { + // adding these breaks generated GraphQL requests + //ID string `json:"id,omitempty"` + //Name string `json:"name,omitempty"` Login string `json:"login"` } @@ -266,13 +274,18 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e createdAt assignees(first: 100) { nodes { + id + name login } totalCount } labels(first: 100) { nodes { + id name + description + color } totalCount } @@ -288,7 +301,10 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e totalCount } milestone { + number title + description + dueOn } reactionGroups { content diff --git a/api/queries_pr.go b/api/queries_pr.go index e3a575562..8a1d0e421 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "sort" "strings" "time" @@ -34,6 +33,7 @@ type PullRequest struct { Number int Title string State string + Closed bool URL string BaseRefName string HeadRefName string @@ -57,12 +57,8 @@ type PullRequest struct { Author Author MergedBy *Author - HeadRepositoryOwner struct { - Login string `json:"login"` - } - HeadRepository struct { - Name string - } + HeadRepositoryOwner Owner + HeadRepository *PRRepository IsCrossRepository bool IsDraft bool MaintainerCanModify bool @@ -77,9 +73,11 @@ type PullRequest struct { Commits struct { TotalCount int - Nodes []struct { + Nodes []PullRequestCommit + } + StatusCheckRollup struct { + Nodes []struct { Commit struct { - Oid string StatusCheckRollup struct { Contexts struct { Nodes []struct { @@ -94,25 +92,56 @@ type PullRequest struct { DetailsURL string `json:"detailsUrl"` TargetURL string `json:"targetUrl,omitempty"` } + PageInfo struct { + HasNextPage bool + EndCursor string + } } } } } } + Assignees Assignees Labels Labels ProjectCards ProjectCards - Milestone Milestone + Milestone *Milestone Comments Comments ReactionGroups ReactionGroups Reviews PullRequestReviews ReviewRequests ReviewRequests } +type PRRepository struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Commit loads just the commit SHA and nothing else type Commit struct { OID string `json:"oid"` } +type PullRequestCommit struct { + Commit PullRequestCommitCommit +} + +// PullRequestCommitCommit contains full information about a commit +type PullRequestCommitCommit struct { + OID string `json:"oid"` + Authors struct { + Nodes []struct { + Name string + Email string + User GitHubUser + } + } + MessageHeadline string + MessageBody string + CommittedDate time.Time + AuthoredDate time.Time +} + type PullRequestFile struct { Path string `json:"path"` Additions int `json:"additions"` @@ -127,7 +156,6 @@ type ReviewRequests struct { Name string `json:"name"` } } - TotalCount int } func (r ReviewRequests) Logins() []string { @@ -138,14 +166,6 @@ func (r ReviewRequests) Logins() []string { return logins } -type NotFoundError struct { - error -} - -func (err *NotFoundError) Unwrap() error { - return err.error -} - func (pr PullRequest) HeadLabel() string { if pr.IsCrossRepository { return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName) @@ -192,10 +212,10 @@ type PullRequestChecksStatus struct { } func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { - if len(pr.Commits.Nodes) == 0 { + if len(pr.StatusCheckRollup.Nodes) == 0 { return } - commit := pr.Commits.Nodes[0].Commit + commit := pr.StatusCheckRollup.Nodes[0].Commit for _, c := range commit.StatusCheckRollup.Contexts.Nodes { state := c.State // StatusContext if state == "" { @@ -247,7 +267,7 @@ func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.Rea } if resp.StatusCode == 404 { - return nil, &NotFoundError{errors.New("pull request not found")} + return nil, errors.New("pull request not found") } else if resp.StatusCode != 200 { return nil, HandleHTTPError(resp) } @@ -560,274 +580,6 @@ func pullRequestFragment(httpClient *http.Client, hostname string) (string, erro return fragments, nil } -func prCommitsFragment(httpClient *http.Client, hostname string) (string, error) { - cachedClient := NewCachedClient(httpClient, time.Hour*24) - if prFeatures, err := determinePullRequestFeatures(cachedClient, hostname); err != nil { - return "", err - } else if !prFeatures.HasStatusCheckRollup { - return "", nil - } - - return ` - commits(last: 1) { - totalCount - nodes { - commit { - oid - statusCheckRollup { - contexts(last: 100) { - nodes { - ...on StatusContext { - context - state - targetUrl - } - ...on CheckRun { - name - status - conclusion - startedAt - completedAt - detailsUrl - } - } - } - } - } - } - } - `, nil -} - -func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*PullRequest, error) { - type response struct { - Repository struct { - PullRequest PullRequest - } - } - - statusesFragment, err := prCommitsFragment(client.http, repo.RepoHost()) - if err != nil { - return nil, err - } - - query := ` - query PullRequestByNumber($owner: String!, $repo: String!, $pr_number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr_number) { - id - url - number - title - state - closed - body - mergeable - additions - deletions - author { - login - } - ` + statusesFragment + ` - baseRefName - headRefName - headRepositoryOwner { - login - } - headRepository { - name - } - isCrossRepository - isDraft - maintainerCanModify - reviewRequests(first: 100) { - nodes { - requestedReviewer { - __typename - ...on User { - login - } - ...on Team { - name - } - } - } - totalCount - } - assignees(first: 100) { - nodes { - login - } - totalCount - } - labels(first: 100) { - nodes { - name - } - totalCount - } - projectCards(first: 100) { - nodes { - project { - name - } - column { - name - } - } - totalCount - } - milestone{ - title - } - ` + commentsFragment() + ` - ` + reactionGroupsFragment() + ` - } - } - }` - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "pr_number": number, - } - - var resp response - err = client.GraphQL(repo.RepoHost(), query, variables, &resp) - if err != nil { - return nil, err - } - - return &resp.Repository.PullRequest, nil -} - -func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters []string) (*PullRequest, error) { - type response struct { - Repository struct { - PullRequests struct { - Nodes []PullRequest - } - } - } - - statusesFragment, err := prCommitsFragment(client.http, repo.RepoHost()) - if err != nil { - return nil, err - } - - query := ` - query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) { - repository(owner: $owner, name: $repo) { - pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { - id - number - title - state - body - mergeable - additions - deletions - author { - login - } - ` + statusesFragment + ` - url - baseRefName - headRefName - headRepositoryOwner { - login - } - headRepository { - name - } - isCrossRepository - isDraft - maintainerCanModify - reviewRequests(first: 100) { - nodes { - requestedReviewer { - __typename - ...on User { - login - } - ...on Team { - name - } - } - } - totalCount - } - assignees(first: 100) { - nodes { - login - } - totalCount - } - labels(first: 100) { - nodes { - name - } - totalCount - } - projectCards(first: 100) { - nodes { - project { - name - } - column { - name - } - } - totalCount - } - milestone{ - title - } - ` + commentsFragment() + ` - ` + reactionGroupsFragment() + ` - } - } - } - }` - - branchWithoutOwner := headBranch - if idx := strings.Index(headBranch, ":"); idx >= 0 { - branchWithoutOwner = headBranch[idx+1:] - } - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "headRefName": branchWithoutOwner, - "states": stateFilters, - } - - var resp response - err = client.GraphQL(repo.RepoHost(), query, variables, &resp) - if err != nil { - return nil, err - } - - prs := resp.Repository.PullRequests.Nodes - sortPullRequestsByState(prs) - - for _, pr := range prs { - if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) { - return &pr, nil - } - } - - return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)} -} - -// sortPullRequestsByState sorts a PullRequest slice by open-first -func sortPullRequestsByState(prs []PullRequest) { - sort.SliceStable(prs, func(a, b int) bool { - return prs[a].State == "OPEN" - }) -} - // CreatePullRequest creates a pull request in a GitHub repository func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) { query := ` diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 030472d75..53eac3e78 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -22,8 +22,11 @@ type PullRequestReviewInput struct { } type PullRequestReviews struct { - Nodes []PullRequestReview - PageInfo PageInfo + Nodes []PullRequestReview + PageInfo struct { + HasNextPage bool + EndCursor string + } TotalCount int } @@ -66,42 +69,6 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables) } -func ReviewsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*PullRequestReviews, error) { - type response struct { - Repository struct { - PullRequest struct { - Reviews PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"` - } `graphql:"pullRequest(number: $number)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "repo": githubv4.String(repo.RepoName()), - "number": githubv4.Int(pr.Number), - "endCursor": (*githubv4.String)(nil), - } - - gql := graphQLClient(client.http, repo.RepoHost()) - - var reviews []PullRequestReview - for { - var query response - err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables) - if err != nil { - return nil, err - } - - reviews = append(reviews, query.Repository.PullRequest.Reviews.Nodes...) - if !query.Repository.PullRequest.Reviews.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Reviews.PageInfo.EndCursor) - } - - return &PullRequestReviews{Nodes: reviews, TotalCount: len(reviews)}, nil -} - func (prr PullRequestReview) AuthorLogin() string { return prr.Author.Login } diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index 5441be950..886f16dd0 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -158,32 +158,3 @@ func Test_determinePullRequestFeatures(t *testing.T) { }) } } - -func Test_sortPullRequestsByState(t *testing.T) { - prs := []PullRequest{ - { - BaseRefName: "test1", - State: "MERGED", - }, - { - BaseRefName: "test2", - State: "CLOSED", - }, - { - BaseRefName: "test3", - State: "OPEN", - }, - } - - sortPullRequestsByState(prs) - - if prs[0].BaseRefName != "test3" { - t.Errorf("prs[0]: got %s, want %q", prs[0].BaseRefName, "test3") - } - if prs[1].BaseRefName != "test1" { - t.Errorf("prs[1]: got %s, want %q", prs[1].BaseRefName, "test1") - } - if prs[2].BaseRefName != "test2" { - t.Errorf("prs[2]: got %s, want %q", prs[2].BaseRefName, "test2") - } -} diff --git a/api/queries_repo.go b/api/queries_repo.go index 90d3949a5..5f1056c26 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -16,25 +16,100 @@ import ( // Repository contains information about a GitHub repo type Repository struct { - ID string - Name string - Description string - URL string - CloneURL string - CreatedAt time.Time - Owner RepositoryOwner + ID string + Name string + NameWithOwner string + Owner RepositoryOwner + Parent *Repository + TemplateRepository *Repository + Description string + HomepageURL string + OpenGraphImageURL string + UsesCustomOpenGraphImage bool + URL string + SSHURL string + MirrorURL string + SecurityPolicyURL string - IsPrivate bool - HasIssuesEnabled bool - HasWikiEnabled bool - ViewerPermission string - DefaultBranchRef BranchRef + CreatedAt time.Time + PushedAt *time.Time + UpdatedAt time.Time - Parent *Repository + IsBlankIssuesEnabled bool + IsSecurityPolicyEnabled bool + HasIssuesEnabled bool + HasProjectsEnabled bool + HasWikiEnabled bool + MergeCommitAllowed bool + SquashMergeAllowed bool + RebaseMergeAllowed bool - MergeCommitAllowed bool - RebaseMergeAllowed bool - SquashMergeAllowed bool + ForkCount int + StargazerCount int + Watchers struct { + TotalCount int `json:"totalCount"` + } + Issues struct { + TotalCount int `json:"totalCount"` + } + PullRequests struct { + TotalCount int `json:"totalCount"` + } + + CodeOfConduct *CodeOfConduct + ContactLinks []ContactLink + DefaultBranchRef BranchRef + DeleteBranchOnMerge bool + DiskUsage int + FundingLinks []FundingLink + IsArchived bool + IsEmpty bool + IsFork bool + IsInOrganization bool + IsMirror bool + IsPrivate bool + IsTemplate bool + IsUserConfigurationRepository bool + LicenseInfo *RepositoryLicense + ViewerCanAdminister bool + ViewerDefaultCommitEmail string + ViewerDefaultMergeMethod string + ViewerHasStarred bool + ViewerPermission string + ViewerPossibleCommitEmails []string + ViewerSubscription string + + RepositoryTopics struct { + Nodes []struct { + Topic RepositoryTopic + } + } + PrimaryLanguage *CodingLanguage + Languages struct { + Edges []struct { + Size int `json:"size"` + Node CodingLanguage `json:"node"` + } + } + IssueTemplates []IssueTemplate + PullRequestTemplates []PullRequestTemplate + Labels struct { + Nodes []IssueLabel + } + Milestones struct { + Nodes []Milestone + } + LatestRelease *RepositoryRelease + + AssignableUsers struct { + Nodes []GitHubUser + } + MentionableUsers struct { + Nodes []GitHubUser + } + Projects struct { + Nodes []RepoProject + } // pseudo-field that keeps track of host name of this repo hostname string @@ -42,12 +117,76 @@ type Repository struct { // RepositoryOwner is the owner of a GitHub repository type RepositoryOwner struct { - Login string + ID string `json:"id"` + Login string `json:"login"` +} + +type GitHubUser struct { + ID string `json:"id"` + Login string `json:"login"` + Name string `json:"name"` } // BranchRef is the branch name in a GitHub repository type BranchRef struct { - Name string + Name string `json:"name"` +} + +type CodeOfConduct struct { + Key string `json:"key"` + Name string `json:"name"` + URL string `json:"url"` +} + +type RepositoryLicense struct { + Key string `json:"key"` + Name string `json:"name"` + Nickname string `json:"nickname"` +} + +type ContactLink struct { + About string `json:"about"` + Name string `json:"name"` + URL string `json:"url"` +} + +type FundingLink struct { + Platform string `json:"platform"` + URL string `json:"url"` +} + +type CodingLanguage struct { + Name string `json:"name"` +} + +type IssueTemplate struct { + Name string `json:"name"` + Title string `json:"title"` + Body string `json:"body"` + About string `json:"about"` +} + +type PullRequestTemplate struct { + Filename string `json:"filename"` + Body string `json:"body"` +} + +type RepositoryTopic struct { + Name string `json:"name"` +} + +type RepositoryRelease struct { + Name string `json:"name"` + TagName string `json:"tagName"` + URL string `json:"url"` + PublishedAt time.Time `json:"publishedAt"` +} + +type IssueLabel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` } // RepoOwner is the login name of the owner @@ -65,11 +204,6 @@ func (r Repository) RepoHost() string { return r.hostname } -// IsFork is true when this repository has a parent repository -func (r Repository) IsFork() bool { - return r.Parent != nil -} - // ViewerCanPush is true when the requesting user has push access func (r Repository) ViewerCanPush() bool { switch r.ViewerPermission { @@ -305,16 +439,26 @@ type repositoryV3 struct { NodeID string Name string CreatedAt time.Time `json:"created_at"` - CloneURL string `json:"clone_url"` Owner struct { Login string } } // ForkRepo forks the repository on GitHub and returns the new repository -func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { +func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, error) { path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo)) - body := bytes.NewBufferString(`{}`) + + params := map[string]interface{}{} + if org != "" { + params["organization"] = org + } + + body := &bytes.Buffer{} + enc := json.NewEncoder(body) + if err := enc.Encode(params); err != nil { + return nil, err + } + result := repositoryV3{} err := client.REST(repo.RepoHost(), "POST", path, body, &result) if err != nil { @@ -324,7 +468,6 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { return &Repository{ ID: result.NodeID, Name: result.Name, - CloneURL: result.CloneURL, CreatedAt: result.CreatedAt, Owner: RepositoryOwner{ Login: result.Owner.Login, @@ -707,9 +850,10 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes } type RepoProject struct { - ID string - Name string - ResourcePath string + ID string `json:"id"` + Name string `json:"name"` + Number int `json:"number"` + ResourcePath string `json:"resourcePath"` } // RepoProjects fetches all open projects for a repository diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index a9d535e6e..50a2067f1 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -144,9 +144,9 @@ func Test_RepoMetadata(t *testing.T) { func Test_ProjectsToPaths(t *testing.T) { expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER"} projects := []RepoProject{ - {"id1", "My Project", "/OWNER/REPO/projects/PROJECT_NUMBER"}, - {"id2", "Org Project", "/orgs/ORG/projects/PROJECT_NUMBER"}, - {"id3", "Project", "/orgs/ORG/projects/PROJECT_NUMBER_2"}, + {ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"}, + {ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"}, + {ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"}, } projectNames := []string{"My Project", "Org Project"} diff --git a/api/query_builder.go b/api/query_builder.go index 84d9d8f91..3bdbb8c9b 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "strings" ) @@ -18,7 +19,7 @@ func shortenQuery(q string) string { } var issueComments = shortenQuery(` - comments(last: 100) { + comments(first: 100) { nodes { author{login}, authorAssociation, @@ -29,25 +30,25 @@ var issueComments = shortenQuery(` minimizedReason, reactionGroups{content,users{totalCount}} }, + pageInfo{hasNextPage,endCursor}, totalCount } `) var prReviewRequests = shortenQuery(` - reviewRequests(last: 100) { + reviewRequests(first: 100) { nodes { requestedReviewer { __typename, ...on User{login}, ...on Team{name} } - }, - totalCount + } } `) var prReviews = shortenQuery(` - reviews(last: 100) { + reviews(first: 100) { nodes { author{login}, authorAssociation, @@ -56,6 +57,7 @@ var prReviews = shortenQuery(` state, reactionGroups{content,users{totalCount}} } + pageInfo{hasNextPage,endCursor} } `) @@ -69,14 +71,38 @@ var prFiles = shortenQuery(` } `) -var prStatusCheckRollup = shortenQuery(` - commits(last: 1) { - totalCount, +var prCommits = shortenQuery(` + commits(first: 100) { nodes { commit { + authors(first:100) { + nodes { + name, + email, + user{id,login} + } + }, + messageHeadline, + messageBody, oid, + committedDate, + authoredDate + } + } + } +`) + +func StatusCheckRollupGraphQL(after string) string { + var afterClause string + if after != "" { + afterClause = ",after:" + after + } + return fmt.Sprintf(shortenQuery(` + statusCheckRollup: commits(last: 1) { + nodes { + commit { statusCheckRollup { - contexts(last: 100) { + contexts(first:100%s) { nodes { __typename ...on StatusContext { @@ -92,13 +118,14 @@ var prStatusCheckRollup = shortenQuery(` completedAt, detailsUrl } - } + }, + pageInfo{hasNextPage,endCursor} } } } } - } -`) + }`), afterClause) +} var IssueFields = []string{ "assignees", @@ -124,6 +151,7 @@ var PullRequestFields = append(IssueFields, "additions", "baseRefName", "changedFiles", + "commits", "deletions", "files", "headRefName", @@ -153,17 +181,17 @@ func PullRequestGraphQL(fields []string) string { case "mergedBy": q = append(q, `mergedBy{login}`) case "headRepositoryOwner": - q = append(q, `headRepositoryOwner{login}`) + q = append(q, `headRepositoryOwner{id,login,...on User{name}}`) case "headRepository": - q = append(q, `headRepository{name}`) + q = append(q, `headRepository{id,name}`) case "assignees": - q = append(q, `assignees(first:100){nodes{login},totalCount}`) + q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`) case "labels": - q = append(q, `labels(first:100){nodes{name},totalCount}`) + q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`) case "projectCards": q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`) case "milestone": - q = append(q, `milestone{title}`) + q = append(q, `milestone{number,title,description,dueOn}`) case "reactionGroups": q = append(q, `reactionGroups{content,users{totalCount}}`) case "mergeCommit": @@ -178,8 +206,142 @@ func PullRequestGraphQL(fields []string) string { q = append(q, prReviews) case "files": q = append(q, prFiles) + case "commits": + q = append(q, prCommits) + case "commitsCount": // pseudo-field + q = append(q, `commits{totalCount}`) case "statusCheckRollup": - q = append(q, prStatusCheckRollup) + q = append(q, StatusCheckRollupGraphQL("")) + default: + q = append(q, field) + } + } + return strings.Join(q, ",") +} + +var RepositoryFields = []string{ + "id", + "name", + "nameWithOwner", + "owner", + "parent", + "templateRepository", + "description", + "homepageUrl", + "openGraphImageUrl", + "usesCustomOpenGraphImage", + "url", + "sshUrl", + "mirrorUrl", + "securityPolicyUrl", + + "createdAt", + "pushedAt", + "updatedAt", + + "isBlankIssuesEnabled", + "isSecurityPolicyEnabled", + "hasIssuesEnabled", + "hasProjectsEnabled", + "hasWikiEnabled", + "mergeCommitAllowed", + "squashMergeAllowed", + "rebaseMergeAllowed", + + "forkCount", + "stargazerCount", + "watchers", + "issues", + "pullRequests", + + "codeOfConduct", + "contactLinks", + "defaultBranchRef", + "deleteBranchOnMerge", + "diskUsage", + "fundingLinks", + "isArchived", + "isEmpty", + "isFork", + "isInOrganization", + "isMirror", + "isPrivate", + "isTemplate", + "isUserConfigurationRepository", + "licenseInfo", + "viewerCanAdminister", + "viewerDefaultCommitEmail", + "viewerDefaultMergeMethod", + "viewerHasStarred", + "viewerPermission", + "viewerPossibleCommitEmails", + "viewerSubscription", + + "repositoryTopics", + "primaryLanguage", + "languages", + "issueTemplates", + "pullRequestTemplates", + "labels", + "milestones", + "latestRelease", + + "assignableUsers", + "mentionableUsers", + "projects", + + // "branchProtectionRules", // too complex to expose + // "collaborators", // does it make sense to expose without affiliation filter? +} + +func RepositoryGraphQL(fields []string) string { + var q []string + for _, field := range fields { + switch field { + case "codeOfConduct": + q = append(q, "codeOfConduct{key,name,url}") + case "contactLinks": + q = append(q, "contactLinks{about,name,url}") + case "fundingLinks": + q = append(q, "fundingLinks{platform,url}") + case "licenseInfo": + q = append(q, "licenseInfo{key,name,nickname}") + case "owner": + q = append(q, "owner{id,login}") + case "parent": + q = append(q, "parent{id,name,owner{id,login}}") + case "templateRepository": + q = append(q, "templateRepository{id,name,owner{id,login}}") + case "repositoryTopics": + q = append(q, "repositoryTopics(first:100){nodes{topic{name}}}") + case "issueTemplates": + q = append(q, "issueTemplates{name,title,body,about}") + case "pullRequestTemplates": + q = append(q, "pullRequestTemplates{body,filename}") + case "labels": + q = append(q, "labels(first:100){nodes{id,color,name,description}}") + case "languages": + q = append(q, "languages(first:100){edges{size,node{name}}}") + case "primaryLanguage": + q = append(q, "primaryLanguage{name}") + case "latestRelease": + q = append(q, "latestRelease{publishedAt,tagName,name,url}") + case "milestones": + q = append(q, "milestones(first:100,states:OPEN){nodes{number,title,description,dueOn}}") + case "assignableUsers": + q = append(q, "assignableUsers(first:100){nodes{id,login,name}}") + case "mentionableUsers": + q = append(q, "mentionableUsers(first:100){nodes{id,login,name}}") + case "projects": + q = append(q, "projects(first:100,states:OPEN){nodes{id,name,number,body,resourcePath}}") + case "watchers": + q = append(q, "watchers{totalCount}") + case "issues": + q = append(q, "issues(states:OPEN){totalCount}") + case "pullRequests": + q = append(q, "pullRequests(states:OPEN){totalCount}") + case "defaultBranchRef": + q = append(q, "defaultBranchRef{name}") default: q = append(q, field) } diff --git a/api/query_builder_test.go b/api/query_builder_test.go index e8d48a10e..7806f2d05 100644 --- a/api/query_builder_test.go +++ b/api/query_builder_test.go @@ -21,7 +21,7 @@ func TestPullRequestGraphQL(t *testing.T) { { name: "fields with nested structures", fields: []string{"author", "assignees"}, - want: "author{login},assignees(first:100){nodes{login},totalCount}", + want: "author{login},assignees(first:100){nodes{id,login,name},totalCount}", }, { name: "compressed query", diff --git a/api/reaction_groups.go b/api/reaction_groups.go index 769edc6aa..08ae53040 100644 --- a/api/reaction_groups.go +++ b/api/reaction_groups.go @@ -57,12 +57,3 @@ var reactionEmoji = map[string]string{ "ROCKET": "\U0001f680", "EYES": "\U0001f440", } - -func reactionGroupsFragment() string { - return `reactionGroups { - content - users { - totalCount - } - }` -} diff --git a/context/context.go b/context/context.go index babd51f55..4c1a64c73 100644 --- a/context/context.go +++ b/context/context.go @@ -104,7 +104,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e if repo == nil { continue } - if repo.IsFork() { + if repo.Parent != nil { add(repo.Parent) } add(repo) diff --git a/docs/project-layout.md b/docs/project-layout.md new file mode 100644 index 000000000..cf594a2e7 --- /dev/null +++ b/docs/project-layout.md @@ -0,0 +1,84 @@ +# GitHub CLI project layout + +At a high level, these areas make up the `github.com/cli/cli` project: +- [`cmd/`](../cmd) - `main` packages for building binaries such as the `gh` executable +- [`pkg/`](../pkg) - most other packages, including the implementation for individual gh commands +- [`docs/`](../docs) - documentation for maintainers and contributors +- [`script/`](../script) - build and release scripts +- [`internal/`](../internal) - Go packages highly specific to our needs and thus internal +- [`go.mod`](../go.mod) - external Go dependencies for this project, automatically fetched by Go at build time + +Some auxiliary Go packages are at the top level of the project for historical reasons: +- [`api/`](../api) - main utilities for making requests to the GitHub API +- [`context/`](../context) - DEPRECATED: use only for referencing git remotes +- [`git/`](../git) - utilities to gather information from a local git repository +- [`test/`](../test) - DEPRECATED: do not use +- [`utils/`](../utils) - DEPRECATED: use only for printing table output + +## Command-line help text + +Running `gh help issue list` displays help text for a topic. In this case, the topic is a specific command, +and help text for every command is embedded in that command's source code. The naming convention for gh +commands is: +``` +pkg/cmd///.go +``` +Following the above example, the main implementation for the `gh issue list` command, including its help +text, is in [pkg/cmd/issue/view/view.go](../pkg/cmd/issue/view/view.go) + +Other help topics not specific to any command, for example `gh help environment`, are found in +[pkg/cmd/root/help_topic.go](../pkg/cmd/root/help_topic.go). + +During our release process, these help topics are [automatically converted](../cmd/gen-docs/main.go) to +manual pages and published under https://cli.github.com/manual/. + +## How GitHub CLI works + +To illustrate how GitHub CLI works in its typical mode of operation, let's build the project, run a command, +and talk through which code gets run in order. + +1. `go run script/build.go` - Makes sure all external Go depedencies are fetched, then compiles the + `cmd/gh/main.go` file into a `bin/gh` binary. +2. `bin/gh issue list --limit 5` - Runs the newly built `bin/gh` binary (note: on Windows you must use + backslashes like `bin\gh`) and passes the following arguments to the process: `["issue", "list", "--limit", "5"]`. +3. `func main()` inside `cmd/gh/main.go` is the first Go function that runs. The arguments passed to the + process are available through `os.Args`. +4. The `main` package initializes the "root" command with `root.NewCmdRoot()` and dispatches execution to it + with `rootCmd.ExecuteC()`. +5. The [root command](../pkg/cmd/root/root.go) represents the top-level `gh` command and knows how to + dispatch execution to any other gh command nested under it. +6. Based on `["issue", "list"]` arguments, the execution reaches the `RunE` block of the `cobra.Command` + within [pkg/cmd/issue/list/list.go](../pkg/cmd/issue/list/list.go). +7. The `--limit 5` flag originally passed as arguments be automatically parsed and its value stored as + `opts.LimitResults`. +8. `func listRun()` is called, which is responsible for implementing the logic of the `gh issue list` command. +9. The command collects information from sources like the GitHub API then writes the final output to + standard output and standard error [streams](../pkg/iostreams/iostreams.go) available at `opts.IO`. +10. The program execution is now back at `func main()` of `cmd/gh/main.go`. If there were any Go errors as a + result of processing the command, the function will abort the process with a non-zero exit status. + Otherwise, the process ends with status 0 indicating success. + +## How to add a new command + +0. First, check on our issue tracker to verify that our team had approved the plans for a new command. +1. Create a package for the new command, e.g. for a new command `gh boom` create the following directory + structure: `pkg/cmd/boom/` +2. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and + returns a `*cobra.Command`. + * Any logic specific to this command should be kept within the command's package and not added to any + "global" packages like `api` or `utils`. +3. Use the method from the previous step to generate the command and add it to the command tree, typically + somewhere in the `NewCmdRoot()` method. + +## How to write tests + +This task might be tricky. Typically, gh commands do things like look up information from the git repository +in the current directory, query the GitHub API, scan the user's `~/.ssh/config` file, clone or fetch git +repositories, etc. Naturally, none of these things should ever happen for real when running tests, unless +you are sure that any filesystem operations are stricly scoped to a location made for and maintained by the +test itself. To avoid actually running things like making real API requests or shelling out to `git` +commands, we stub them. You should look at how that's done within some existing tests. + +To make your code testable, write small, isolated pieces of functionality that are designed to be composed +together. Prefer table-driven tests for maintaining variations of different test inputs and expectations +when exercising a single piece of functionality. diff --git a/go.mod b/go.mod index b44012c93..96b74584c 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/rivo/uniseg v0.1.0 github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f - github.com/spf13/cobra v1.1.1 + github.com/spf13/cobra v1.1.3 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.6.1 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 diff --git a/go.sum b/go.sum index 523a32a85..d1f1f19db 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4= -github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= +github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -419,7 +419,7 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config_file.go b/internal/config/config_file.go index 540bafa46..e3d7d2d52 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -3,10 +3,8 @@ package config import ( "errors" "fmt" - "io" "io/ioutil" "os" - "path" "path/filepath" "syscall" @@ -111,7 +109,7 @@ var ReadConfigFile = func(filename string) ([]byte, error) { } var WriteConfigFile = func(filename string, data []byte) error { - err := os.MkdirAll(path.Dir(filename), 0771) + err := os.MkdirAll(filepath.Dir(filename), 0771) if err != nil { return pathError(err) } @@ -122,11 +120,7 @@ var WriteConfigFile = func(filename string, data []byte) error { } defer cfgFile.Close() - n, err := cfgFile.Write(data) - if err == nil && n < len(data) { - err = io.ErrShortWrite - } - + _, err = cfgFile.Write(data) return err } @@ -263,7 +257,7 @@ func findRegularFile(p string) string { if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() { return p } - newPath := path.Dir(p) + newPath := filepath.Dir(p) if newPath == p || newPath == "/" || newPath == "." { break } diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index f12629d7a..c7343b7ea 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -3,7 +3,9 @@ package config import ( "bytes" "fmt" + "io/ioutil" "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -167,3 +169,28 @@ func Test_ConfigDir(t *testing.T) { }) } } + +func Test_configFile_Write_toDisk(t *testing.T) { + configDir := filepath.Join(t.TempDir(), ".config", "gh") + os.Setenv(GH_CONFIG_DIR, configDir) + defer os.Unsetenv(GH_CONFIG_DIR) + + cfg := NewFromString(`pager: less`) + err := cfg.Write() + if err != nil { + t.Fatal(err) + } + + expectedConfig := "pager: less\n" + if configBytes, err := ioutil.ReadFile(filepath.Join(configDir, "config.yml")); err != nil { + t.Error(err) + } else if string(configBytes) != expectedConfig { + t.Errorf("expected config.yml %q, got %q", expectedConfig, string(configBytes)) + } + + if configBytes, err := ioutil.ReadFile(filepath.Join(configDir, "hosts.yml")); err != nil { + t.Error(err) + } else if string(configBytes) != "" { + t.Errorf("unexpected hosts.yml: %q", string(configBytes)) + } +} diff --git a/internal/docs/man_test.go b/internal/docs/man_test.go index 58591e19e..d72ea7214 100644 --- a/internal/docs/man_test.go +++ b/internal/docs/man_test.go @@ -175,11 +175,10 @@ func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error { } func BenchmarkGenManToFile(b *testing.B) { - file, err := ioutil.TempFile("", "") + file, err := ioutil.TempFile(b.TempDir(), "") if err != nil { b.Fatal(err) } - defer os.Remove(file.Name()) defer file.Close() b.ResetTimer() diff --git a/internal/docs/markdown_test.go b/internal/docs/markdown_test.go index 27be95efa..497a0384a 100644 --- a/internal/docs/markdown_test.go +++ b/internal/docs/markdown_test.go @@ -83,11 +83,10 @@ func TestGenMdTree(t *testing.T) { } func BenchmarkGenMarkdownToFile(b *testing.B) { - file, err := ioutil.TempFile("", "") + file, err := ioutil.TempFile(b.TempDir(), "") if err != nil { b.Fatal(err) } - defer os.Remove(file.Name()) defer file.Close() b.ResetTimer() diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index 33ba4b1a7..356ca1581 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -48,26 +48,26 @@ func actionsExplainer(cs *iostreams.ColorScheme) string { GitHub CLI integrates with Actions to help you manage runs and workflows. - %s - gh run list: List recent workflow runs - gh run view: View details for a workflow run or one of its jobs - gh run watch: Watch a workflow run while it executes - gh run rerun: Rerun a failed workflow run + %s + gh run list: List recent workflow runs + gh run view: View details for a workflow run or one of its jobs + gh run watch: Watch a workflow run while it executes + gh run rerun: Rerun a failed workflow run gh run download: Download artifacts generated by runs To see more help, run 'gh help run ' - %s - gh workflow list: List all the workflow files in your repository - gh workflow view: View details for a workflow file - gh workflow enable: Enable a workflow file - gh workflow disable: Disable a workflow file + %s + gh workflow list: List all the workflow files in your repository + gh workflow view: View details for a workflow file + gh workflow enable: Enable a workflow file + gh workflow disable: Disable a workflow file gh workflow run: Trigger a workflow_dispatch run for a workflow file To see more help, run 'gh help workflow ' For more in depth help including examples, see online documentation at: - https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli + `, header, runHeader, workflowHeader) } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index acaff19da..0d7290835 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -693,6 +693,9 @@ func Test_apiRun_inputFile(t *testing.T) { contentLength: 10, }, } + + tempDir := t.TempDir() + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { io, stdin, _, _ := iostreams.Test() @@ -702,13 +705,12 @@ func Test_apiRun_inputFile(t *testing.T) { if tt.inputFile == "-" { _, _ = stdin.Write(tt.inputContents) } else { - f, err := ioutil.TempFile("", tt.inputFile) + f, err := ioutil.TempFile(tempDir, tt.inputFile) if err != nil { t.Fatal(err) } _, _ = f.Write(tt.inputContents) - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) + defer f.Close() inputFile = f.Name() } @@ -825,13 +827,13 @@ func Test_parseFields(t *testing.T) { } func Test_magicFieldValue(t *testing.T) { - f, err := ioutil.TempFile("", "gh-test") + f, err := ioutil.TempFile(t.TempDir(), "gh-test") if err != nil { t.Fatal(err) } + defer f.Close() + fmt.Fprint(f, "file contents") - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) io, _, _, _ := iostreams.Test() @@ -932,13 +934,13 @@ func Test_magicFieldValue(t *testing.T) { } func Test_openUserFile(t *testing.T) { - f, err := ioutil.TempFile("", "gh-test") + f, err := ioutil.TempFile(t.TempDir(), "gh-test") if err != nil { t.Fatal(err) } + defer f.Close() + fmt.Fprint(f, "file contents") - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) file, length, err := openUserFile(f.Name(), nil) if err != nil { diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index 24414a5fc..7af5271ca 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -21,7 +21,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { When installing GitHub CLI through a package manager, it's possible that no additional shell configuration is necessary to gain completion support. For - Homebrew, see https://docs.brew.sh/Shell-Completion + Homebrew, see If you need to set up completions manually, follow the instructions below. The exact config file locations might vary based on your system. Make sure to restart your diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 645638bca..c10b5d27b 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -151,7 +151,13 @@ func createRun(opts *CreateOptions) error { var httpError api.HTTPError if errors.As(err, &httpError) { if httpError.OAuthScopes != "" && !strings.Contains(httpError.OAuthScopes, "gist") { - return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate by doing `gh config set -h github.com oauth_token ''` and running the command again.") + return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h %s -s gist", host) + } + if httpError.StatusCode == http.StatusUnprocessableEntity { + if detectEmptyFiles(files) { + fmt.Fprintf(errOut, "%s Failed to create gist: %s\n", cs.FailureIcon(), "a gist file cannot be blank") + return cmdutil.SilentError + } } } return fmt.Errorf("%s Failed to create gist: %w", cs.Red("X"), err) @@ -266,3 +272,12 @@ func createGist(client *http.Client, hostname, description string, public bool, return &result, nil } + +func detectEmptyFiles(files map[string]*shared.GistFile) bool { + for _, file := range files { + if strings.TrimSpace(file.Content) == "" { + return true + } + } + return false +} diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index 0c2d0f39f..b454b1598 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -5,9 +5,11 @@ import ( "encoding/json" "io/ioutil" "net/http" + "path" "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmd/gist/shared" @@ -18,10 +20,6 @@ import ( "github.com/stretchr/testify/assert" ) -const ( - fixtureFile = "../fixture.txt" -) - func Test_processFiles(t *testing.T) { fakeStdin := strings.NewReader("hey cool how is it going") files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"}) @@ -164,15 +162,22 @@ func TestNewCmdCreate(t *testing.T) { } func Test_createRun(t *testing.T) { + tempDir := t.TempDir() + fixtureFile := path.Join(tempDir, "fixture.txt") + assert.NoError(t, ioutil.WriteFile(fixtureFile, []byte("{}"), 0644)) + emptyFile := path.Join(tempDir, "empty.txt") + assert.NoError(t, ioutil.WriteFile(emptyFile, []byte(" \t\n"), 0644)) + tests := []struct { - name string - opts *CreateOptions - stdin string - wantOut string - wantStderr string - wantParams map[string]interface{} - wantErr bool - wantBrowse string + name string + opts *CreateOptions + stdin string + wantOut string + wantStderr string + wantParams map[string]interface{} + wantErr bool + wantBrowse string + responseStatus int }{ { name: "public", @@ -193,6 +198,7 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, { name: "with description", @@ -213,6 +219,7 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, { name: "multiple files", @@ -236,6 +243,28 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, + }, + { + name: "file with empty content", + opts: &CreateOptions{ + Filenames: []string{emptyFile}, + }, + wantOut: "", + wantStderr: heredoc.Doc(` + - Creating gist empty.txt + X Failed to create gist: a gist file cannot be blank + `), + wantErr: true, + wantParams: map[string]interface{}{ + "description": "", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "empty.txt": map[string]interface{}{"content": " \t\n"}, + }, + }, + responseStatus: http.StatusUnprocessableEntity, }, { name: "stdin arg", @@ -256,6 +285,7 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, { name: "web arg", @@ -277,14 +307,22 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, } for _, tt := range tests { reg := &httpmock.Registry{} - reg.Register(httpmock.REST("POST", "gists"), - httpmock.JSONResponse(struct { - Html_url string - }{"https://gist.github.com/aa5a315d61ae9438b18d"})) + if tt.responseStatus == http.StatusOK { + reg.Register( + httpmock.REST("POST", "gists"), + httpmock.StringResponse(`{ + "html_url": "https://gist.github.com/aa5a315d61ae9438b18d" + }`)) + } else { + reg.Register( + httpmock.REST("POST", "gists"), + httpmock.StatusStringResponse(tt.responseStatus, "{}")) + } mockClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil @@ -325,6 +363,32 @@ func Test_createRun(t *testing.T) { } } +func Test_detectEmptyFiles(t *testing.T) { + tests := []struct { + content string + isEmptyFile bool + }{ + { + content: "{}", + isEmptyFile: false, + }, + { + content: "\n\t", + isEmptyFile: true, + }, + } + + for _, tt := range tests { + files := map[string]*shared.GistFile{} + files["file"] = &shared.GistFile{ + Content: tt.content, + } + + isEmptyFile := detectEmptyFiles(files) + assert.Equal(t, tt.isEmptyFile, isEmptyFile) + } +} + func Test_CreateRun_reauth(t *testing.T) { reg := &httpmock.Registry{} reg.Register(httpmock.REST("POST", "gists"), func(req *http.Request) (*http.Response, error) { @@ -332,33 +396,24 @@ func Test_CreateRun_reauth(t *testing.T) { StatusCode: 404, Request: req, Header: map[string][]string{ - "X-Oauth-Scopes": {"coolScope"}, + "X-Oauth-Scopes": {"repo, read:org"}, }, Body: ioutil.NopCloser(bytes.NewBufferString("oh no")), }, nil }) - mockClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - io, _, _, _ := iostreams.Test() opts := &CreateOptions{ - IO: io, - HttpClient: mockClient, + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, - Filenames: []string{fixtureFile}, } err := createRun(opts) - if err == nil { - t.Fatalf("expected oauth error") - } - - if !strings.Contains(err.Error(), "Please re-authenticate") { - t.Errorf("got unexpected error: %s", err) - } + assert.EqualError(t, err, "This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h github.com -s gist") } diff --git a/pkg/cmd/gist/fixture.txt b/pkg/cmd/gist/fixture.txt deleted file mode 100644 index 9e26dfeeb..000000000 --- a/pkg/cmd/gist/fixture.txt +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 98d66a9f8..1eac50d32 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -20,7 +20,7 @@ type GistFile struct { Filename string `json:"filename,omitempty"` Type string `json:"type,omitempty"` Language string `json:"language,omitempty"` - Content string `json:"content,omitempty"` + Content string `json:"content"` } type GistOwner struct { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index eece13daf..3e69f386c 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -6,7 +6,6 @@ import ( "fmt" "io/ioutil" "net/http" - "os" "path/filepath" "strings" "testing" @@ -422,8 +421,9 @@ func TestIssueCreate_recover(t *testing.T) { }, }) - tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*") + tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*") assert.NoError(t, err) + defer tmpfile.Close() state := prShared.IssueMetadataState{ Title: "recovered title", diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index b3f85a36b..5e6f0583e 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -149,7 +149,9 @@ func editRun(opts *EditOptions) error { editable.Assignees.Default = issue.Assignees.Logins() editable.Labels.Default = issue.Labels.Names() editable.Projects.Default = issue.ProjectCards.ProjectNames() - editable.Milestone.Default = issue.Milestone.Title + if issue.Milestone != nil { + editable.Milestone.Default = issue.Milestone.Title + } if opts.Interactive { err = opts.FieldsToEditSurvey(&editable) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index ff3aba976..c543a84dc 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error { defer opts.IO.StopPager() if opts.Exporter != nil { - data := api.ExportIssues(listResult.Issues, opts.Exporter.Fields()) - return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled()) } if isTerminal { diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go index 764ec52fa..2a8c97ce8 100644 --- a/pkg/cmd/issue/status/status.go +++ b/pkg/cmd/issue/status/status.go @@ -96,11 +96,11 @@ func statusRun(opts *StatusOptions) error { if opts.Exporter != nil { data := map[string]interface{}{ - "createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Exporter.Fields()), - "assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Exporter.Fields()), - "mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Exporter.Fields()), + "createdBy": issuePayload.Authored.Issues, + "assigned": issuePayload.Assigned.Issues, + "mentioned": issuePayload.Mentioned.Issues, } - return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled()) } out := opts.IO.Out diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json index 9ab620ecd..f45459e24 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json @@ -1,7 +1,6 @@ { "data": { - "repository": { - "issue": { + "node": { "comments": { "nodes": [ { @@ -315,6 +314,5 @@ "totalCount": 6 } } - } } } diff --git a/pkg/cmd/issue/view/http.go b/pkg/cmd/issue/view/http.go new file mode 100644 index 000000000..c4f87d677 --- /dev/null +++ b/pkg/cmd/issue/view/http.go @@ -0,0 +1,50 @@ +package view + +import ( + "context" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" +) + +func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api.Issue) error { + type response struct { + Node struct { + Issue struct { + Comments api.Comments `graphql:"comments(first: 100, after: $endCursor)"` + } `graphql:"...on Issue"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(issue.ID), + "endCursor": (*githubv4.String)(nil), + } + if issue.Comments.PageInfo.HasNextPage { + variables["endCursor"] = githubv4.String(issue.Comments.PageInfo.EndCursor) + } else { + issue.Comments.Nodes = issue.Comments.Nodes[0:0] + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client) + for { + var query response + err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables) + if err != nil { + return err + } + + issue.Comments.Nodes = append(issue.Comments.Nodes, query.Node.Issue.Comments.Nodes...) + if !query.Node.Issue.Comments.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Node.Issue.Comments.PageInfo.EndCursor) + } + + issue.Comments.PageInfo.HasNextPage = false + return nil +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index e90c6a528..78be7df09 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/markdown" + "github.com/cli/cli/pkg/set" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) @@ -82,9 +83,17 @@ func viewRun(opts *ViewOptions) error { if err != nil { return err } - apiClient := api.NewClientFromHTTP(httpClient) - issue, repo, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + loadComments := opts.Comments + if !loadComments && opts.Exporter != nil { + fields := set.NewStringSet() + fields.AddValues(opts.Exporter.Fields()) + loadComments = fields.Contains("comments") + } + + opts.IO.StartProgressIndicator() + issue, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, loadComments) + opts.IO.StopProgressIndicator() if err != nil { return err } @@ -97,27 +106,14 @@ func viewRun(opts *ViewOptions) error { return opts.Browser.Browse(openURL) } - if opts.Comments { - opts.IO.StartProgressIndicator() - comments, err := api.CommentsForIssue(apiClient, repo, issue) - opts.IO.StopProgressIndicator() - if err != nil { - return err - } - issue.Comments = *comments - } - opts.IO.DetectTerminalTheme() - - err = opts.IO.StartPager() - if err != nil { - return err + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } defer opts.IO.StopPager() if opts.Exporter != nil { - exportIssue := issue.ExportData(opts.Exporter.Fields()) - return opts.Exporter.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled()) } if opts.IO.IsStdoutTTY() { @@ -132,6 +128,19 @@ func viewRun(opts *ViewOptions) error { return printRawIssuePreview(opts.IO.Out, issue) } +func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, loadComments bool) (*api.Issue, error) { + apiClient := api.NewClientFromHTTP(client) + issue, repo, err := issueShared.IssueFromArg(apiClient, baseRepoFn, selector) + if err != nil { + return issue, err + } + + if loadComments { + err = preloadIssueComments(client, repo, issue) + } + return issue, err +} + func printRawIssuePreview(out io.Writer, issue *api.Issue) error { assignees := issueAssigneeList(*issue) labels := shared.IssueLabelList(*issue) @@ -146,7 +155,11 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) fmt.Fprintf(out, "assignees:\t%s\n", assignees) fmt.Fprintf(out, "projects:\t%s\n", projects) - fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title) + var milestoneTitle string + if issue.Milestone != nil { + milestoneTitle = issue.Milestone.Title + } + fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) fmt.Fprintln(out, "--") fmt.Fprintln(out, issue.Body) return nil @@ -187,7 +200,7 @@ func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error { fmt.Fprint(out, cs.Bold("Projects: ")) fmt.Fprintln(out, projects) } - if issue.Milestone.Title != "" { + if issue.Milestone != nil { fmt.Fprint(out, cs.Bold("Milestone: ")) fmt.Fprintln(out, issue.Milestone.Title) } diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index f7f73bb28..0647aebb2 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -24,10 +24,11 @@ type CheckoutOptions struct { HttpClient func() (*http.Client, error) Config func() (config.Config, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) Remotes func() (context.Remotes, error) Branch func() (string, error) + Finder shared.PRFinder + SelectorArg string RecurseSubmodules bool Force bool @@ -48,8 +49,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr Short: "Check out a pull request in git", Args: cmdutil.ExactArgs(1, "argument required"), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if len(args) > 0 { opts.SelectorArg = args[0] @@ -70,18 +70,11 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr } func checkoutRun(opts *CheckoutOptions) error { - remotes, err := opts.Remotes() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}, } - - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } @@ -90,8 +83,12 @@ func checkoutRun(opts *CheckoutOptions) error { if err != nil { return err } - protocol, _ := cfg.Get(baseRepo.RepoHost(), "git_protocol") + + remotes, err := opts.Remotes() + if err != nil { + return err + } baseRemote, _ := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()) baseURLOrName := ghrepo.FormatRemoteURL(baseRepo, protocol) if baseRemote != nil { @@ -112,6 +109,12 @@ func checkoutRun(opts *CheckoutOptions) error { if headRemote != nil { cmdQueue = append(cmdQueue, cmdsForExistingRemote(headRemote, pr, opts)...) } else { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + defaultBranch, err := api.RepoDefaultBranch(apiClient, baseRepo) if err != nil { return err diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index ed1775f44..48150ff84 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -2,9 +2,9 @@ package checkout import ( "bytes" - "encoding/json" "io/ioutil" "net/http" + "strings" "testing" "github.com/cli/cli/api" @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -21,6 +22,43 @@ import ( "github.com/stretchr/testify/assert" ) +// repo: either "baseOwner/baseRepo" or "baseOwner/baseRepo:defaultBranch" +// prHead: "headOwner/headRepo:headBranch" +func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) { + defaultBranch := "" + if idx := strings.IndexRune(repo, ':'); idx >= 0 { + defaultBranch = repo[idx+1:] + repo = repo[:idx] + } + baseRepo, err := ghrepo.FromFullName(repo) + if err != nil { + panic(err) + } + if defaultBranch != "" { + baseRepo = api.InitRepoHostname(&api.Repository{ + Name: baseRepo.RepoName(), + Owner: api.RepositoryOwner{Login: baseRepo.RepoOwner()}, + DefaultBranchRef: api.BranchRef{Name: defaultBranch}, + }, baseRepo.RepoHost()) + } + + idx := strings.IndexRune(prHead, ':') + headRefName := prHead[idx+1:] + headRepo, err := ghrepo.FromFullName(prHead[:idx]) + if err != nil { + panic(err) + } + + return baseRepo, &api.PullRequest{ + Number: 123, + HeadRefName: headRefName, + HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()}, + HeadRepository: &api.PRRepository{Name: headRepo.RepoName()}, + IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo), + MaintainerCanModify: false, + } +} + func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() @@ -32,13 +70,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, - BaseRepo: func() (ghrepo.Interface, error) { - return api.InitRepoHostname(&api.Repository{ - Name: "REPO", - Owner: api.RepositoryOwner{Login: "OWNER"}, - DefaultBranchRef: api.BranchRef{Name: "master"}, - }, "github.com"), nil - }, Remotes: func() (context.Remotes, error) { if remotes == nil { return context.Remotes{ @@ -78,20 +109,9 @@ func TestPRCheckout_sameRepo(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") + finder := shared.RunCommandFinder("123", pr, baseRepo) + finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -103,141 +123,17 @@ func TestPRCheckout_sameRepo(t *testing.T) { cs.Register(`git config branch\.feature\.merge refs/heads/feature`, 0, "") output, err := runCommand(http, nil, "master", `123`) - if !assert.NoError(t, err) { - return - } - - assert.Equal(t, "", output.String()) -} - -func TestPRCheckout_urlArg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } - `)) - - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "") - cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "") - cs.Register(`git checkout -b feature --no-track origin/feature`, 0, "") - cs.Register(`git config branch\.feature\.remote origin`, 0, "") - cs.Register(`git config branch\.feature\.merge refs/heads/feature`, 0, "") - - output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`) - assert.NoError(t, err) - assert.Equal(t, "", output.String()) -} - -func TestPRCheckout_urlArg_differentBase(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "POE" - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } - `)) - http.StubRepoInfoResponse("OWNER", "REPO", "master") - - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(`git fetch https://github\.com/OTHER/POE\.git refs/pull/123/head:feature`, 0, "") - cs.Register(`git config branch\.feature\.merge`, 1, "") - cs.Register(`git checkout feature`, 0, "") - cs.Register(`git config branch\.feature\.remote https://github\.com/OTHER/POE\.git`, 0, "") - cs.Register(`git config branch\.feature\.merge refs/pull/123/head`, 0, "") - - output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`) - assert.NoError(t, err) - assert.Equal(t, "", output.String()) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) - reqBody := struct { - Variables struct { - Owner string - Repo string - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "OTHER", reqBody.Variables.Owner) - assert.Equal(t, "POE", reqBody.Variables.Repo) -} - -func TestPRCheckout_branchArg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": false } - ] } } } } - `)) - - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "") - cs.Register(`git config branch\.feature\.merge`, 1, "") - cs.Register(`git checkout feature`, 0, "") - cs.Register(`git config branch\.feature\.remote origin`, 0, "") - cs.Register(`git config branch\.feature\.merge refs/pull/123/head`, 0, "") - - output, err := runCommand(http, nil, "master", `hubot:feature`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_existingBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -250,6 +146,7 @@ func TestPRCheckout_existingBranch(t *testing.T) { output, err := runCommand(http, nil, "master", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { @@ -267,20 +164,9 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:feature") + finder := shared.RunCommandFinder("123", pr, baseRepo) + finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -294,26 +180,16 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { output, err := runCommand(http, remotes, "master", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_differentRepo(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") + finder := shared.RunCommandFinder("123", pr, baseRepo) + finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -327,26 +203,15 @@ func TestPRCheckout_differentRepo(t *testing.T) { output, err := runCommand(http, nil, "master", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -358,26 +223,15 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { output, err := runCommand(http, nil, "master", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_detachedHead(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": true - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -389,26 +243,15 @@ func TestPRCheckout_detachedHead(t *testing.T) { output, err := runCommand(http, nil, "", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -420,26 +263,15 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { output, err := runCommand(http, nil, "feature", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "-foo", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:-foo") + shared.RunCommandFinder("123", pr, baseRepo) _, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -447,26 +279,16 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { output, err := runCommand(http, nil, "master", `123`) assert.EqualError(t, err, `invalid branch name: "-foo"`) assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_maintainerCanModify(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": true - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") + pr.MaintainerCanModify = true + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -480,25 +302,14 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { output, err := runCommand(http, nil, "master", `123`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_recurseSubmodules(t *testing.T) { http := &httpmock.Registry{} - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -513,25 +324,14 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) { output, err := runCommand(http, nil, "master", `123 --recurse-submodules`) assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_force(t *testing.T) { http := &httpmock.Registry{} - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -545,26 +345,15 @@ func TestPRCheckout_force(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRCheckout_detach(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "headRef": "f8f8f8", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO" - }, - "isCrossRepository": true, - "maintainerCanModify": true - } } } } - `)) + baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -573,6 +362,7 @@ func TestPRCheckout_detach(t *testing.T) { cs.Register(`git fetch origin refs/pull/123/head`, 0, "") output, err := runCommand(http, nil, "", `123 --detach`) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 1a11f3d44..b21631820 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -3,13 +3,10 @@ package checks import ( "errors" "fmt" - "net/http" "sort" "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" @@ -23,26 +20,19 @@ type browser interface { } type ChecksOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - Browser browser - BaseRepo func() (ghrepo.Interface, error) - Branch func() (string, error) - Remotes func() (context.Remotes, error) + IO *iostreams.IOStreams + Browser browser - WebMode bool + Finder shared.PRFinder SelectorArg string + WebMode bool } func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Command { opts := &ChecksOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Branch: f.Branch, - Remotes: f.Remotes, - BaseRepo: f.BaseRepo, - Browser: f.Browser, + IO: f.IOStreams, + Browser: f.Browser, } cmd := &cobra.Command{ @@ -56,8 +46,7 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} @@ -81,25 +70,17 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co } func checksRun(opts *ChecksOptions) error { - httpClient, err := opts.HttpClient() + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"number", "baseRefName", "statusCheckRollup"}, + } + if opts.WebMode { + findOptions.Fields = []string{"number"} + } + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) - if err != nil { - return err - } - - if len(pr.Commits.Nodes) == 0 { - return fmt.Errorf("no commit found on the pull request") - } - - rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes - if len(rollup) == 0 { - return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName) - } isTerminal := opts.IO.IsStdoutTTY() @@ -111,6 +92,15 @@ func checksRun(opts *ChecksOptions) error { return opts.Browser.Browse(openURL) } + if len(pr.StatusCheckRollup.Nodes) == 0 { + return fmt.Errorf("no commit found on the pull request") + } + + rollup := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes + if len(rollup) == 0 { + return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName) + } + passing := 0 failing := 0 pending := 0 @@ -128,7 +118,7 @@ func checksRun(opts *ChecksOptions) error { outputs := []output{} - for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { + for _, c := range pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { mark := "✓" bucket := "pass" state := c.State diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index fdcce3f9e..0a189bd4d 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -2,16 +2,20 @@ package checks import ( "bytes" - "net/http" + "encoding/json" + "io" + "os" "testing" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdChecks(t *testing.T) { @@ -66,36 +70,20 @@ func Test_checksRun(t *testing.T) { tests := []struct { name string fixture string - stubs func(*httpmock.Registry) + prJSON string nontty bool wantOut string wantErr string }{ { - name: "no commits", - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } - `)) - }, + name: "no commits", + prJSON: `{ "number": 123 }`, wantOut: "", wantErr: "no commit found on the pull request", }, { - name: "no checks", - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } - } } } - `)) - }, + name: "no checks", + prJSON: `{ "number": 123, "statusCheckRollup": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, wantOut: "", wantErr: "no checks reported on the 'master' branch", }, @@ -124,17 +112,9 @@ func Test_checksRun(t *testing.T) { wantErr: "SilentError", }, { - name: "no checks", - nontty: true, - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } - } } } - `)) - }, + name: "no checks", + nontty: true, + prJSON: `{ "number": 123, "statusCheckRollup": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, wantOut: "", wantErr: "no checks reported on the 'master' branch", }, @@ -170,28 +150,26 @@ func Test_checksRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - io, _, stdout, _ := iostreams.Test() - io.SetStdoutTTY(!tt.nontty) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(!tt.nontty) + + var response *api.PullRequest + var jsonReader io.Reader + if tt.fixture != "" { + ff, err := os.Open(tt.fixture) + require.NoError(t, err) + defer ff.Close() + jsonReader = ff + } else { + jsonReader = bytes.NewBufferString(tt.prJSON) + } + dec := json.NewDecoder(jsonReader) + require.NoError(t, dec.Decode(&response)) opts := &ChecksOptions{ - IO: io, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, + IO: ios, SelectorArg: "123", - } - - reg := &httpmock.Registry{} - defer reg.Verify(t) - - if tt.stubs != nil { - tt.stubs(reg) - } else if tt.fixture != "" { - reg.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture)) - } - - opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil + Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")), } err := checksRun(opts) @@ -232,10 +210,6 @@ func TestChecksRun_web(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { browser := &cmdutil.TestBrowser{} - reg := &httpmock.Registry{} - - reg.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse("./fixtures/allPassing.json")) io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(tc.isTTY) @@ -246,21 +220,15 @@ func TestChecksRun_web(t *testing.T) { defer teardown(t) err := checksRun(&ChecksOptions{ - IO: io, - Browser: browser, - WebMode: true, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, + IO: io, + Browser: browser, + WebMode: true, SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO")), }) assert.NoError(t, err) assert.Equal(t, tc.wantStdout, stdout.String()) assert.Equal(t, tc.wantStderr, stderr.String()) - reg.Verify(t) browser.Verify(t, tc.wantBrowse) }) } diff --git a/pkg/cmd/pr/checks/fixtures/allPassing.json b/pkg/cmd/pr/checks/fixtures/allPassing.json index c75e3fc29..8d1f33510 100644 --- a/pkg/cmd/pr/checks/fixtures/allPassing.json +++ b/pkg/cmd/pr/checks/fixtures/allPassing.json @@ -1,6 +1,6 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { @@ -37,5 +37,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/checks/fixtures/someFailing.json b/pkg/cmd/pr/checks/fixtures/someFailing.json index 0e53cdb79..f407cd4b5 100644 --- a/pkg/cmd/pr/checks/fixtures/someFailing.json +++ b/pkg/cmd/pr/checks/fixtures/someFailing.json @@ -1,6 +1,6 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { @@ -37,5 +37,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/checks/fixtures/somePending.json b/pkg/cmd/pr/checks/fixtures/somePending.json index 6e36a5cd3..2d558f39e 100644 --- a/pkg/cmd/pr/checks/fixtures/somePending.json +++ b/pkg/cmd/pr/checks/fixtures/somePending.json @@ -1,6 +1,6 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { @@ -37,5 +37,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/checks/fixtures/withStatuses.json b/pkg/cmd/pr/checks/fixtures/withStatuses.json index 0ce8b9c66..ddc7374ba 100644 --- a/pkg/cmd/pr/checks/fixtures/withStatuses.json +++ b/pkg/cmd/pr/checks/fixtures/withStatuses.json @@ -1,6 +1,6 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { @@ -34,5 +34,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 850bd7631..2c9442ecb 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -6,8 +6,6 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/git" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -16,11 +14,11 @@ import ( type CloseOptions struct { HttpClient func() (*http.Client, error) - Config func() (config.Config, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) Branch func() (string, error) + Finder shared.PRFinder + SelectorArg string DeleteBranch bool DeleteLocalBranch bool @@ -30,7 +28,6 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm opts := &CloseOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Config: f.Config, Branch: f.Branch, } @@ -39,8 +36,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm Short: "Close a pull request", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if len(args) > 0 { opts.SelectorArg = args[0] @@ -62,25 +58,29 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm func closeRun(opts *CloseOptions) error { cs := opts.IO.ColorScheme() - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"state", "number", "title", "isCrossRepository", "headRefName"}, } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg) + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } if pr.State == "MERGED" { - fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged", cs.Red("!"), pr.Number, pr.Title) + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged\n", cs.FailureIcon(), pr.Number, pr.Title) return cmdutil.SilentError } else if !pr.IsOpen() { - fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", cs.Yellow("!"), pr.Number, pr.Title) + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", cs.WarningIcon(), pr.Number, pr.Title) return nil } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + err = api.PullRequestClose(apiClient, baseRepo, pr) if err != nil { return fmt.Errorf("API call failed: %w", err) @@ -88,12 +88,10 @@ func closeRun(opts *CloseOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Red), pr.Number, pr.Title) - crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() - if opts.DeleteBranch { branchSwitchString := "" - if opts.DeleteLocalBranch && !crossRepoPR { + if opts.DeleteLocalBranch { currentBranch, err := opts.Branch() if err != nil { return err @@ -113,10 +111,8 @@ func closeRun(opts *CloseOptions) error { localBranchExists := git.HasLocalBranch(pr.HeadRefName) if localBranchExists { - err = git.DeleteLocalBranch(pr.HeadRefName) - if err != nil { - err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) - return err + if err := git.DeleteLocalBranch(pr.HeadRefName); err != nil { + return fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) } } @@ -125,11 +121,14 @@ func closeRun(opts *CloseOptions) error { } } - if !crossRepoPR { - err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) - if err != nil { - err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) - return err + if pr.IsCrossRepository { + fmt.Fprintf(opts.IO.ErrOut, "%s Skipped deleting the remote branch of a pull request from fork\n", cs.WarningIcon()) + if !opts.DeleteLocalBranch { + return nil + } + } else { + if err := api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName); err != nil { + return fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) } } fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.SuccessIconWithColor(cs.Red), cs.Cyan(pr.HeadRefName), branchSwitchString) diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 4aa239384..c94fe83f3 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -4,12 +4,14 @@ import ( "bytes" "io/ioutil" "net/http" - "regexp" + "strings" "testing" - "github.com/cli/cli/internal/config" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -18,6 +20,44 @@ import ( "github.com/stretchr/testify/assert" ) +// repo: either "baseOwner/baseRepo" or "baseOwner/baseRepo:defaultBranch" +// prHead: "headOwner/headRepo:headBranch" +func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) { + defaultBranch := "" + if idx := strings.IndexRune(repo, ':'); idx >= 0 { + defaultBranch = repo[idx+1:] + repo = repo[:idx] + } + baseRepo, err := ghrepo.FromFullName(repo) + if err != nil { + panic(err) + } + if defaultBranch != "" { + baseRepo = api.InitRepoHostname(&api.Repository{ + Name: baseRepo.RepoName(), + Owner: api.RepositoryOwner{Login: baseRepo.RepoOwner()}, + DefaultBranchRef: api.BranchRef{Name: defaultBranch}, + }, baseRepo.RepoHost()) + } + + idx := strings.IndexRune(prHead, ':') + headRefName := prHead[idx+1:] + headRepo, err := ghrepo.FromFullName(prHead[:idx]) + if err != nil { + panic(err) + } + + return baseRepo, &api.PullRequest{ + ID: "THE-ID", + Number: 96, + State: "OPEN", + HeadRefName: headRefName, + HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()}, + HeadRepository: &api.PRRepository{Name: headRepo.RepoName()}, + IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo), + } +} + func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -29,12 +69,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, Branch: func() (string, error) { return "trunk", nil }, @@ -63,13 +97,10 @@ func TestPrClose(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR", "state": "OPEN" } - } } }`), - ) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") + pr.Title = "The title of the PR" + shared.RunCommandFinder("96", pr, baseRepo) + http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), httpmock.GraphQLMutation(`{"id": "THE-ID"}`, @@ -79,57 +110,34 @@ func TestPrClose(t *testing.T) { ) output, err := runCommand(http, true, "96") - if err != nil { - t.Fatalf("error running command `pr close`: %v", err) - } - - r := regexp.MustCompile(`Closed pull request #96 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Closed pull request #96 (The title of the PR)\n", output.Stderr()) } func TestPrClose_alreadyClosed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 101, "title": "The title of the PR", "state": "CLOSED" } - } } }`), - ) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") + pr.State = "CLOSED" + pr.Title = "The title of the PR" + shared.RunCommandFinder("96", pr, baseRepo) - output, err := runCommand(http, true, "101") - if err != nil { - t.Fatalf("error running command `pr close`: %v", err) - } - - r := regexp.MustCompile(`Pull request #101 \(The title of the PR\) is already closed`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "96") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "! Pull request #96 (The title of the PR) is already closed\n", output.Stderr()) } -func TestPrClose_deleteBranch(t *testing.T) { +func TestPrClose_deleteBranch_sameRepo(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 96, - "title": "The title of the PR", - "headRefName":"blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "state": "OPEN" } - } } }`), - ) + baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:blueberries") + pr.Title = "The title of the PR" + shared.RunCommandFinder("96", pr, baseRepo) + http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), httpmock.GraphQLMutation(`{"id": "THE-ID"}`, @@ -148,10 +156,77 @@ func TestPrClose_deleteBranch(t *testing.T) { cs.Register(`git branch -D blueberries`, 0, "") output, err := runCommand(http, true, `96 --delete-branch`) - if err != nil { - t.Fatalf("Got unexpected error running `pr close` %s", err) - } - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), `Closed pull request #96 \(The title of the PR\)`, `Deleted branch blueberries`) + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + ✓ Closed pull request #96 (The title of the PR) + ✓ Deleted branch blueberries + `), output.Stderr()) +} + +func TestPrClose_deleteBranch_crossRepo(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:blueberries") + pr.Title = "The title of the PR" + shared.RunCommandFinder("96", pr, baseRepo) + + http.Register( + httpmock.GraphQL(`mutation PullRequestClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") + cs.Register(`git branch -D blueberries`, 0, "") + + output, err := runCommand(http, true, `96 --delete-branch`) + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + ✓ Closed pull request #96 (The title of the PR) + ! Skipped deleting the remote branch of a pull request from fork + ✓ Deleted branch blueberries + `), output.Stderr()) +} + +func TestPrClose_deleteBranch_sameBranch(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + baseRepo, pr := stubPR("OWNER/REPO:main", "OWNER/REPO:trunk") + pr.Title = "The title of the PR" + shared.RunCommandFinder("96", pr, baseRepo) + + http.Register( + httpmock.GraphQL(`mutation PullRequestClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git checkout main`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/trunk`, 0, "") + cs.Register(`git branch -D trunk`, 0, "") + + output, err := runCommand(http, true, `96 --delete-branch`) + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + ✓ Closed pull request #96 (The title of the PR) + ✓ Deleted branch trunk and switched to branch main + `), output.Stderr()) } diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index a9ec5e9d3..85845259c 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -2,11 +2,8 @@ package comment import ( "errors" - "net/http" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" @@ -48,7 +45,13 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err if len(args) > 0 { selector = args[0] } - opts.RetrieveCommentable = retrievePR(f.HttpClient, f.BaseRepo, f.Branch, f.Remotes, selector) + finder := shared.NewFinder(f) + opts.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { + return finder.Find(shared.FindOptions{ + Selector: selector, + Fields: []string{"id", "url"}, + }) + } return shared.CommentablePreRun(cmd, opts) }, RunE: func(cmd *cobra.Command, args []string) error { @@ -74,24 +77,3 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err return cmd } - -func retrievePR(httpClient func() (*http.Client, error), - baseRepo func() (ghrepo.Interface, error), - branch func() (string, error), - remotes func() (context.Remotes, error), - selector string) func() (shared.Commentable, ghrepo.Interface, error) { - return func() (shared.Commentable, ghrepo.Interface, error) { - httpClient, err := httpClient() - if err != nil { - return nil, nil, err - } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, repo, err := shared.PRFromArgs(apiClient, baseRepo, branch, remotes, selector) - if err != nil { - return nil, nil, err - } - - return pr, repo, nil - } -} diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index 429af7cda..859a57069 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "github.com/cli/cli/context" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" @@ -224,7 +224,6 @@ func Test_commentRun(t *testing.T) { ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestFromNumber(t, reg) mockCommentCreate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", @@ -238,9 +237,6 @@ func Test_commentRun(t *testing.T) { OpenInBrowser: func(string) error { return nil }, }, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestFromNumber(t, reg) - }, stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n", }, { @@ -253,7 +249,6 @@ func Test_commentRun(t *testing.T) { EditSurvey: func() (string, error) { return "comment body", nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestFromNumber(t, reg) mockCommentCreate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", @@ -266,7 +261,6 @@ func Test_commentRun(t *testing.T) { Body: "comment body", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestFromNumber(t, reg) mockCommentCreate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", @@ -280,16 +274,20 @@ func Test_commentRun(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - tt.httpStubs(t, reg) + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - branch := func() (string, error) { return "", nil } - remotes := func() (context.Remotes, error) { return nil, nil } tt.input.IO = io tt.input.HttpClient = httpClient - tt.input.RetrieveCommentable = retrievePR(httpClient, baseRepo, branch, remotes, "123") + tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { + return &api.PullRequest{ + Number: 123, + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO"), nil + } t.Run(tt.name, func(t *testing.T) { err := shared.CommentableRun(tt.input) @@ -300,17 +298,6 @@ func Test_commentRun(t *testing.T) { } } -func mockPullRequestFromNumber(_ *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "number": 123, - "url": "https://github.com/OWNER/REPO/pull/123" - } } } }`), - ) -} - func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation CommentCreate\b`), diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index fc2e70e26..7ab899e85 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -36,6 +36,7 @@ type CreateOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) Browser browser + Finder shared.PRFinder TitleProvided bool BodyProvided bool @@ -117,6 +118,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co `), Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { + opts.Finder = shared.NewFinder(f) + opts.TitleProvided = cmd.Flags().Changed("title") opts.RepoOverride, _ = cmd.Flags().GetString("repo") noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit") @@ -220,9 +223,13 @@ func createRun(opts *CreateOptions) (err error) { state.Body = opts.Body } - existingPR, err := api.PullRequestForBranch( - client, ctx.BaseRepo, ctx.BaseBranch, ctx.HeadBranchLabel, []string{"OPEN"}) - var notFound *api.NotFoundError + existingPR, _, err := opts.Finder.Find(shared.FindOptions{ + Selector: ctx.HeadBranchLabel, + BaseBranch: ctx.BaseBranch, + States: []string{"OPEN"}, + Fields: []string{"url"}, + }) + var notFound *shared.NotFoundError if err != nil && !errors.As(err, ¬Found) { return fmt.Errorf("error checking for existing pull request: %w", err) } @@ -674,7 +681,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { // one by forking the base repository if headRepo == nil && ctx.IsPushEnabled { opts.IO.StartProgressIndicator() - headRepo, err = api.ForkRepo(client, ctx.BaseRepo) + headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "") opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error forking repo: %w", err) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 8480462ed..a3d24c4d6 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -6,7 +6,6 @@ import ( "fmt" "io/ioutil" "net/http" - "os" "path/filepath" "testing" @@ -17,6 +16,7 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/pr/shared" prShared "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" @@ -247,12 +247,7 @@ func TestPRCreate_recover(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), httpmock.StringResponse(` @@ -309,8 +304,9 @@ func TestPRCreate_recover(t *testing.T) { }, }) - tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*") + tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*") assert.NoError(t, err) + defer tmpfile.Close() state := prShared.IssueMetadataState{ Title: "recovered title", @@ -337,12 +333,7 @@ func TestPRCreate_nontty(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } }`), - ) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -379,12 +370,7 @@ func TestPRCreate(t *testing.T) { http.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -428,12 +414,7 @@ func TestPRCreate_NoMaintainerModify(t *testing.T) { http.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -477,12 +458,7 @@ func TestPRCreate_createFork(t *testing.T) { http.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`)) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.REST("POST", "repos/OWNER/REPO/forks"), httpmock.StatusStringResponse(201, ` @@ -544,12 +520,7 @@ func TestPRCreate_pushedToNonBaseRepo(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -588,12 +559,7 @@ func TestPRCreate_pushedToDifferentBranchName(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -634,12 +600,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -684,12 +645,7 @@ func TestPRCreate_metadata(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - ] } } } } - `)) + shared.RunCommandFinder("feature", nil, nil) http.Register( httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), httpmock.StringResponse(` @@ -790,15 +746,7 @@ func TestPRCreate_alreadyExists(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("feature", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/123"}, ghrepo.New("OWNER", "REPO")) _, err := runCommand(http, nil, "feature", true, `-t title -b body -H feature`) assert.EqualError(t, err, "a pull request for branch \"feature\" into branch \"master\" already exists:\nhttps://github.com/OWNER/REPO/pull/123") diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 00a41c657..fa040a4aa 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -11,8 +11,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -22,9 +20,8 @@ import ( type DiffOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) - Branch func() (string, error) + + Finder shared.PRFinder SelectorArg string UseColor string @@ -34,8 +31,6 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman opts := &DiffOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Remotes: f.Remotes, - Branch: f.Branch, } cmd := &cobra.Command{ @@ -49,8 +44,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} @@ -81,17 +75,21 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman } func diffRun(opts *DiffOptions) error { + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"number"}, + } + pr, baseRepo, err := opts.Finder.Find(findOptions) + if err != nil { + return err + } + httpClient, err := opts.HttpClient() if err != nil { return err } apiClient := api.NewClientFromHTTP(httpClient) - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) - if err != nil { - return err - } - diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number) if err != nil { return fmt.Errorf("could not find pull request diff: %w", err) diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 2e81116a4..e98690f93 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" + "github.com/cli/cli/api" "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -119,27 +119,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Remotes: func() (context.Remotes, error) { - if remotes == nil { - return context.Remotes{ - { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, nil - } - - return remotes, nil - }, - Branch: func() (string, error) { - return "feature", nil - }, } cmd := NewCmdDiff(factory, nil) @@ -161,61 +140,15 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s }, err } -func TestPRDiff_no_current_pr(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequests": { "nodes": [] } - } } }`), - ) - - _, err := runCommand(http, nil, false, "") - assert.EqualError(t, err, `no pull requests found for branch "feature"`) -} - -func TestPRDiff_argument_not_found(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } }`), - ) - http.Register( - httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), - httpmock.StatusStringResponse(404, ""), - ) - - _, err := runCommand(http, nil, false, "123") - assert.EqualError(t, err, `could not find pull request diff: pull request not found`) -} - func TestPRDiff_notty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), - httpmock.StringResponse(testDiff), - ) + httpmock.StringResponse(testDiff)) output, err := runCommand(http, nil, false, "") if err != nil { @@ -230,23 +163,14 @@ func TestPRDiff_tty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), httpmock.StringResponse(testDiff), ) - output, err := runCommand(http, nil, true, "") + output, err := runCommand(http, nil, true, "123") if err != nil { t.Fatalf("unexpected error: %s", err) } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index dbf0321f9..13481bcba 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -7,7 +7,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" shared "github.com/cli/cli/pkg/cmd/pr/shared" @@ -20,10 +19,8 @@ import ( type EditOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) - Branch func() (string, error) + Finder shared.PRFinder Surveyor Surveyor Fetcher EditableOptionsFetcher EditorRetriever EditorRetriever @@ -38,8 +35,6 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Remotes: f.Remotes, - Branch: f.Branch, Surveyor: surveyor{}, Fetcher: fetcher{}, EditorRetriever: editorRetriever{config: f.Config}, @@ -66,8 +61,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if len(args) > 0 { opts.SelectorArg = args[0] @@ -155,13 +149,11 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman } func editRun(opts *EditOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "milestone"}, } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, repo, err := opts.Finder.Find(findOptions) if err != nil { return err } @@ -175,7 +167,9 @@ func editRun(opts *EditOptions) error { editable.Assignees.Default = pr.Assignees.Logins() editable.Labels.Default = pr.Labels.Names() editable.Projects.Default = pr.ProjectCards.ProjectNames() - editable.Milestone.Default = pr.Milestone.Title + if pr.Milestone != nil { + editable.Milestone.Default = pr.Milestone.Title + } if opts.Interactive { err = opts.Surveyor.FieldsToEdit(&editable) @@ -184,6 +178,12 @@ func editRun(opts *EditOptions) error { } } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + opts.IO.StartProgressIndicator() err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable) opts.IO.StopProgressIndicator() diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 586036910..12203b99a 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -309,6 +309,9 @@ func Test_editRun(t *testing.T) { name: "non-interactive", input: &EditOptions{ SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), Interactive: false, Editable: shared.Editable{ Title: shared.EditableString{ @@ -351,7 +354,6 @@ func Test_editRun(t *testing.T) { Fetcher: testFetcher{}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestGet(t, reg) mockRepoMetadata(t, reg, false) mockPullRequestUpdate(t, reg) mockPullRequestReviewersUpdate(t, reg) @@ -362,6 +364,9 @@ func Test_editRun(t *testing.T) { name: "non-interactive skip reviewers", input: &EditOptions{ SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), Interactive: false, Editable: shared.Editable{ Title: shared.EditableString{ @@ -399,7 +404,6 @@ func Test_editRun(t *testing.T) { Fetcher: testFetcher{}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestGet(t, reg) mockRepoMetadata(t, reg, true) mockPullRequestUpdate(t, reg) }, @@ -408,14 +412,16 @@ func Test_editRun(t *testing.T) { { name: "interactive", input: &EditOptions{ - SelectorArg: "123", + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), Interactive: true, Surveyor: testSurveyor{}, Fetcher: testFetcher{}, EditorRetriever: testEditorRetriever{}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestGet(t, reg) mockRepoMetadata(t, reg, false) mockPullRequestUpdate(t, reg) mockPullRequestReviewersUpdate(t, reg) @@ -425,14 +431,16 @@ func Test_editRun(t *testing.T) { { name: "interactive skip reviewers", input: &EditOptions{ - SelectorArg: "123", + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), Interactive: true, Surveyor: testSurveyor{skipReviewers: true}, Fetcher: testFetcher{}, EditorRetriever: testEditorRetriever{}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - mockPullRequestGet(t, reg) mockRepoMetadata(t, reg, true) mockPullRequestUpdate(t, reg) }, @@ -450,11 +458,9 @@ func Test_editRun(t *testing.T) { tt.httpStubs(t, reg) httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } tt.input.IO = io tt.input.HttpClient = httpClient - tt.input.BaseRepo = baseRepo t.Run(tt.name, func(t *testing.T) { err := editRun(tt.input) @@ -465,18 +471,6 @@ func Test_editRun(t *testing.T) { } } -func mockPullRequestGet(_ *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "456", - "number": 123, - "url": "https://github.com/OWNER/REPO/pull/123" - } } } }`), - ) -} - func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) { reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), @@ -551,23 +545,13 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) func mockPullRequestUpdate(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation PullRequestUpdate\b`), - httpmock.GraphQLMutation(` - { "data": { "updatePullRequest": { "pullRequest": { - "id": "456" - } } } }`, - func(inputs map[string]interface{}) {}), - ) + httpmock.StringResponse(`{}`)) } func mockPullRequestReviewersUpdate(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`), - httpmock.GraphQLMutation(` - { "data": { "requestReviews": { "pullRequest": { - "id": "456" - } } } }`, - func(inputs map[string]interface{}) {}), - ) + httpmock.StringResponse(`{}`)) } type testFetcher struct{} diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index f0ccfe11a..faf7a3e24 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error { defer opts.IO.StopPager() if opts.Exporter != nil { - data := api.ExportPRs(listResult.PullRequests, opts.Exporter.Fields()) - return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled()) } if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 31a91d6be..f25732a7b 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -8,10 +8,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -26,12 +24,11 @@ type editor interface { type MergeOptions struct { HttpClient func() (*http.Client, error) - Config func() (config.Config, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) Branch func() (string, error) + Finder shared.PRFinder + SelectorArg string DeleteBranch bool MergeMethod PullRequestMergeMethod @@ -52,8 +49,6 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts := &MergeOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Config: f.Config, - Remotes: f.Remotes, Branch: f.Branch, } @@ -76,8 +71,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} @@ -136,7 +130,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts.Editor = &userEditor{ io: opts.IO, - config: opts.Config, + config: f.Config, } if runF != nil { @@ -160,19 +154,23 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm func mergeRun(opts *MergeOptions) error { cs := opts.IO.ColorScheme() - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"id", "number", "state", "title", "commits", "mergeable", "headRepositoryOwner", "headRefName"}, } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } isTerminal := opts.IO.IsStdoutTTY() + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + if opts.AutoMergeDisable { err := disableAutoMerge(httpClient, baseRepo, pr.ID) if err != nil { @@ -186,7 +184,7 @@ func mergeRun(opts *MergeOptions) error { if opts.SelectorArg == "" && len(pr.Commits.Nodes) > 0 { if localBranchLastCommit, err := git.LastCommit(); err == nil { - if localBranchLastCommit.Sha != pr.Commits.Nodes[0].Commit.Oid { + if localBranchLastCommit.Sha != pr.Commits.Nodes[len(pr.Commits.Nodes)-1].Commit.OID { fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) has diverged from local branch\n", cs.Yellow("!"), pr.Number, pr.Title) } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 16ad0ed6d..abd0d9f55 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -13,11 +13,9 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -197,6 +195,20 @@ func Test_NewCmdMerge(t *testing.T) { } } +func baseRepo(owner, repo, branch string) ghrepo.Interface { + return api.InitRepoHostname(&api.Repository{ + Name: repo, + Owner: api.RepositoryOwner{Login: owner}, + DefaultBranchRef: api.BranchRef{Name: branch}, + }, "github.com") +} + +func stubCommit(pr *api.PullRequest, oid string) { + pr.Commits.Nodes = append(pr.Commits.Nodes, api.PullRequestCommit{ + Commit: api.PullRequestCommitCommit{OID: oid}, + }) +} + func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -208,24 +220,6 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return api.InitRepoHostname(&api.Repository{ - Name: "REPO", - Owner: api.RepositoryOwner{Login: "OWNER"}, - DefaultBranchRef: api.BranchRef{Name: "master"}, - }, "github.com"), nil - }, - Remotes: func() (context.Remotes, error) { - return context.Remotes{ - { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, nil - }, Branch: func() (string, error) { return branch, nil }, @@ -259,17 +253,18 @@ func initFakeHTTP() *httpmock.Registry { func TestPrMerge(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 1, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) + + shared.RunCommandFinder( + "1", + &api.PullRequest{ + ID: "THE-ID", + Number: 1, + State: "OPEN", + Title: "The title of the PR", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -296,17 +291,18 @@ func TestPrMerge(t *testing.T) { func TestPrMerge_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 1, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) + + shared.RunCommandFinder( + "1", + &api.PullRequest{ + ID: "THE-ID", + Number: 1, + State: "OPEN", + Title: "The title of the PR", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -330,17 +326,18 @@ func TestPrMerge_nontty(t *testing.T) { func TestPrMerge_withRepoFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 1, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) + + shared.RunCommandFinder( + "1", + &api.PullRequest{ + ID: "THE-ID", + Number: 1, + State: "OPEN", + Title: "The title of the PR", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -367,10 +364,19 @@ func TestPrMerge_withRepoFlag(t *testing.T) { func TestPrMerge_deleteBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - // FIXME: references fixture from another package - httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "PR_10", + Number: 10, + State: "OPEN", + Title: "Blueberries are a good fruit", + HeadRefName: "blueberries", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -385,8 +391,6 @@ func TestPrMerge_deleteBranch(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git .+ show .+ HEAD`, 1, "") - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") cs.Register(`git checkout master`, 0, "") cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") cs.Register(`git branch -D blueberries`, 0, "") @@ -406,10 +410,19 @@ func TestPrMerge_deleteBranch(t *testing.T) { func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - // FIXME: references fixture from another package - httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + + shared.RunCommandFinder( + "blueberries", + &api.PullRequest{ + ID: "PR_10", + Number: 10, + State: "OPEN", + Title: "Blueberries are a good fruit", + HeadRefName: "blueberries", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -439,59 +452,24 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { `), output.Stderr()) } -func TestPrMerge_noPrNumberGiven(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - // FIXME: references fixture from another package - httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - assert.NotContains(t, input, "commitHeadline") - })) - - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(`git .+ show .+ HEAD`, 1, "") - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - - output, err := runCommand(http, "blueberries", true, "pr merge --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - assert.Equal(t, "", output.String()) - assert.Equal(t, heredoc.Doc(` - ✓ Merged pull request #10 (Blueberries are a good fruit) - `), output.Stderr()) -} - func Test_nonDivergingPullRequest(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "PR_10", - "title": "Blueberries are a good fruit", - "number": 10, - "commits": { - "nodes": [{ - "commit": { - "oid": "COMMITSHA1" - } - }], - "totalCount": 1 - } - }] } } } }`)) + + pr := &api.PullRequest{ + ID: "PR_10", + Number: 10, + Title: "Blueberries are a good fruit", + State: "OPEN", + } + stubCommit(pr, "COMMITSHA1") + + shared.RunCommandFinder( + "", + pr, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -504,7 +482,6 @@ func Test_nonDivergingPullRequest(t *testing.T) { defer cmdTeardown(t) cs.Register(`git .+ show .+ HEAD`, 0, "COMMITSHA1,title") - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") output, err := runCommand(http, "blueberries", true, "pr merge --merge") if err != nil { @@ -519,24 +496,21 @@ func Test_nonDivergingPullRequest(t *testing.T) { func Test_divergingPullRequestWarning(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "PR_10", - "title": "Blueberries are a good fruit", - "number": 10, - "commits": { - "nodes": [{ - "commit": { - "oid": "COMMITSHA1" - } - }], - "totalCount": 1 - } - }] } } } }`)) + + pr := &api.PullRequest{ + ID: "PR_10", + Number: 10, + Title: "Blueberries are a good fruit", + State: "OPEN", + } + stubCommit(pr, "COMMITSHA1") + + shared.RunCommandFinder( + "", + pr, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -549,7 +523,6 @@ func Test_divergingPullRequestWarning(t *testing.T) { defer cmdTeardown(t) cs.Register(`git .+ show .+ HEAD`, 0, "COMMITSHA2,title") - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") output, err := runCommand(http, "blueberries", true, "pr merge --merge") if err != nil { @@ -565,20 +538,18 @@ func Test_divergingPullRequestWarning(t *testing.T) { func Test_pullRequestWithoutCommits(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "PR_10", - "title": "Blueberries are a good fruit", - "number": 10, - "commits": { - "nodes": [], - "totalCount": 0 - } - }] } } } }`)) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "PR_10", + Number: 10, + Title: "Blueberries are a good fruit", + State: "OPEN", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -587,11 +558,9 @@ func Test_pullRequestWithoutCommits(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) - cs, cmdTeardown := run.Stub() + _, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - output, err := runCommand(http, "blueberries", true, "pr merge --merge") if err != nil { t.Fatalf("error running command `pr merge`: %v", err) @@ -605,17 +574,18 @@ func Test_pullRequestWithoutCommits(t *testing.T) { func TestPrMerge_rebase(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 2, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) + + shared.RunCommandFinder( + "2", + &api.PullRequest{ + ID: "THE-ID", + Number: 2, + Title: "The title of the PR", + State: "OPEN", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -642,17 +612,18 @@ func TestPrMerge_rebase(t *testing.T) { func TestPrMerge_squash(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 3, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) + + shared.RunCommandFinder( + "3", + &api.PullRequest{ + ID: "THE-ID", + Number: 3, + Title: "The title of the PR", + State: "OPEN", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -678,22 +649,18 @@ func TestPrMerge_squash(t *testing.T) { func TestPrMerge_alreadyMerged(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { - "number": 4, - "title": "The title of the PR", - "state": "MERGED", - "baseRefName": "master", - "headRefName": "blueberries", - "headRepositoryOwner": { - "login": "OWNER" - }, - "isCrossRepository": false - } - } } }`)) + + shared.RunCommandFinder( + "4", + &api.PullRequest{ + ID: "THE-ID", + Number: 4, + State: "MERGED", + HeadRefName: "blueberries", + BaseRefName: "master", + }, + baseRepo("OWNER", "REPO", "master"), + ) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -707,23 +674,25 @@ func TestPrMerge_alreadyMerged(t *testing.T) { as.StubOne(true) output, err := runCommand(http, "blueberries", true, "pr merge 4") - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "✓ Deleted branch blueberries and switched to branch master") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Deleted branch blueberries and switched to branch master\n", output.Stderr()) } func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} - } } }`)) + + shared.RunCommandFinder( + "4", + &api.PullRequest{ + ID: "THE-ID", + Number: 4, + State: "MERGED", + HeadRepositoryOwner: api.Owner{Login: "monalisa"}, + }, + baseRepo("OWNER", "REPO", "master"), + ) _, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -740,15 +709,18 @@ func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) { func TestPRMerge_interactive(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "THE-ID", - "number": 3 - }] } } } }`)) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "THE-ID", + Number: 3, + Title: "It was the best of times", + HeadRefName: "blueberries", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(` @@ -765,11 +737,9 @@ func TestPRMerge_interactive(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) - cs, cmdTeardown := run.Stub() + _, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - as, surveyTeardown := prompt.InitAskStubber() defer surveyTeardown() @@ -789,16 +759,18 @@ func TestPRMerge_interactive(t *testing.T) { func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "THE-ID", - "title": "It was the best of times", - "number": 3 - }] } } } }`)) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "THE-ID", + Number: 3, + Title: "It was the best of times", + HeadRefName: "blueberries", + }, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(` @@ -821,7 +793,6 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") cs.Register(`git checkout master`, 0, "") cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") cs.Register(`git branch -D blueberries`, 0, "") @@ -851,16 +822,6 @@ func TestPRMerge_interactiveSquashEditCommitMsg(t *testing.T) { tr := initFakeHTTP() defer tr.Verify(t) - - tr.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "headRepositoryOwner": {"login": "OWNER"}, - "id": "THE-ID", - "number": 3, - "title": "title" - } } } }`)) tr.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(` @@ -902,25 +863,28 @@ func TestPRMerge_interactiveSquashEditCommitMsg(t *testing.T) { }, SelectorArg: "https://github.com/OWNER/REPO/pull/123", InteractiveMode: true, + Finder: shared.NewMockFinder( + "https://github.com/OWNER/REPO/pull/123", + &api.PullRequest{ID: "THE-ID", Number: 123, Title: "title"}, + ghrepo.New("OWNER", "REPO"), + ), }) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) - assert.Equal(t, "✓ Squashed and merged pull request #3 (title)\n", stderr.String()) + assert.Equal(t, "✓ Squashed and merged pull request #123 (title)\n", stderr.String()) } func TestPRMerge_interactiveCancelled(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "THE-ID", - "number": 3 - }] } } } }`)) + + shared.RunCommandFinder( + "", + &api.PullRequest{ID: "THE-ID", Number: 123}, + ghrepo.New("OWNER", "REPO"), + ) + http.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(` @@ -930,11 +894,9 @@ func TestPRMerge_interactiveCancelled(t *testing.T) { "squashMergeAllowed": true } } }`)) - cs, cmdTeardown := run.Stub() + _, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - as, surveyTeardown := prompt.InitAskStubber() defer surveyTeardown() @@ -971,18 +933,6 @@ func TestMergeRun_autoMerge(t *testing.T) { tr := initFakeHTTP() defer tr.Verify(t) - - tr.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 123, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) tr.Register( httpmock.GraphQL(`mutation PullRequestAutoMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -1001,6 +951,11 @@ func TestMergeRun_autoMerge(t *testing.T) { SelectorArg: "https://github.com/OWNER/REPO/pull/123", AutoMergeEnable: true, MergeMethod: PullRequestMergeMethodSquash, + Finder: shared.NewMockFinder( + "https://github.com/OWNER/REPO/pull/123", + &api.PullRequest{ID: "THE-ID", Number: 123}, + ghrepo.New("OWNER", "REPO"), + ), }) assert.NoError(t, err) @@ -1015,21 +970,11 @@ func TestMergeRun_disableAutoMerge(t *testing.T) { tr := initFakeHTTP() defer tr.Verify(t) - - tr.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 123, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) tr.Register( httpmock.GraphQL(`mutation PullRequestAutoMergeDisable\b`), - httpmock.StringResponse(`{}`)) + httpmock.GraphQLQuery(`{}`, func(s string, m map[string]interface{}) { + assert.Equal(t, map[string]interface{}{"prID": "THE-ID"}, m) + })) _, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -1041,6 +986,11 @@ func TestMergeRun_disableAutoMerge(t *testing.T) { }, SelectorArg: "https://github.com/OWNER/REPO/pull/123", AutoMergeDisable: true, + Finder: shared.NewMockFinder( + "https://github.com/OWNER/REPO/pull/123", + &api.PullRequest{ID: "THE-ID", Number: 123}, + ghrepo.New("OWNER", "REPO"), + ), }) assert.NoError(t, err) diff --git a/pkg/cmd/pr/ready/ready.go b/pkg/cmd/pr/ready/ready.go index 78b20532a..6f4212057 100644 --- a/pkg/cmd/pr/ready/ready.go +++ b/pkg/cmd/pr/ready/ready.go @@ -7,9 +7,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -18,11 +15,9 @@ import ( type ReadyOptions struct { HttpClient func() (*http.Client, error) - Config func() (config.Config, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) - Branch func() (string, error) + + Finder shared.PRFinder SelectorArg string } @@ -31,9 +26,6 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm opts := &ReadyOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Config: f.Config, - Remotes: f.Remotes, - Branch: f.Branch, } cmd := &cobra.Command{ @@ -47,8 +39,7 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} @@ -71,25 +62,29 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm func readyRun(opts *ReadyOptions) error { cs := opts.IO.ColorScheme() - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"id", "number", "state", "isDraft"}, } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } if !pr.IsOpen() { - fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", cs.Red("!"), pr.Number) + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"\n", cs.FailureIcon(), pr.Number) return cmdutil.SilentError } else if !pr.IsDraft { - fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", cs.Yellow("!"), pr.Number) + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", cs.WarningIcon(), pr.Number) return nil } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + err = api.PullRequestReady(apiClient, baseRepo, pr) if err != nil { return fmt.Errorf("API call failed: %w", err) diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go index a53a15d24..b4a39d452 100644 --- a/pkg/cmd/pr/ready/ready_test.go +++ b/pkg/cmd/pr/ready/ready_test.go @@ -4,13 +4,11 @@ import ( "bytes" "io/ioutil" "net/http" - "regexp" "testing" - "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/config" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -101,23 +99,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Remotes: func() (context.Remotes, error) { - return context.Remotes{ - { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, nil - }, - Branch: func() (string, error) { - return "main", nil - }, } cmd := NewCmdReady(factory, nil) @@ -143,13 +124,13 @@ func TestPRReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "id": "THE-ID", "number": 444, "state": "OPEN", "isDraft": true} - } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{ + ID: "THE-ID", + Number: 123, + State: "OPEN", + IsDraft: true, + }, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.GraphQL(`mutation PullRequestReadyForReview\b`), httpmock.GraphQLMutation(`{"id": "THE-ID"}`, @@ -158,62 +139,42 @@ func TestPRReady(t *testing.T) { }), ) - output, err := runCommand(http, true, "444") - if err != nil { - t.Fatalf("error running command `pr ready`: %v", err) - } - - r := regexp.MustCompile(`Pull request #444 is marked as "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "123") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Pull request #123 is marked as \"ready for review\"\n", output.Stderr()) } func TestPRReady_alreadyReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 445, "state": "OPEN", "isDraft": false} - } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{ + ID: "THE-ID", + Number: 123, + State: "OPEN", + IsDraft: false, + }, ghrepo.New("OWNER", "REPO")) - output, err := runCommand(http, true, "445") - if err != nil { - t.Fatalf("error running command `pr ready`: %v", err) - } - - r := regexp.MustCompile(`Pull request #445 is already "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "123") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "! Pull request #123 is already \"ready for review\"\n", output.Stderr()) } func TestPRReady_closed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 446, "state": "CLOSED", "isDraft": true} - } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{ + ID: "THE-ID", + Number: 123, + State: "CLOSED", + IsDraft: true, + }, ghrepo.New("OWNER", "REPO")) - output, err := runCommand(http, true, "446") - if err == nil { - t.Fatalf("expected an error running command `pr ready` on a review that is closed!: %v", err) - } - - r := regexp.MustCompile(`Pull request #446 is closed. Only draft pull requests can be marked as "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "123") + assert.EqualError(t, err, "SilentError") + assert.Equal(t, "", output.String()) + assert.Equal(t, "X Pull request #123 is closed. Only draft pull requests can be marked as \"ready for review\"\n", output.Stderr()) } diff --git a/pkg/cmd/pr/reopen/reopen.go b/pkg/cmd/pr/reopen/reopen.go index f22b8bfd2..2750d1cec 100644 --- a/pkg/cmd/pr/reopen/reopen.go +++ b/pkg/cmd/pr/reopen/reopen.go @@ -5,8 +5,6 @@ import ( "net/http" "github.com/cli/cli/api" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -15,9 +13,9 @@ import ( type ReopenOptions struct { HttpClient func() (*http.Client, error) - Config func() (config.Config, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) + + Finder shared.PRFinder SelectorArg string } @@ -26,7 +24,6 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co opts := &ReopenOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Config: f.Config, } cmd := &cobra.Command{ @@ -34,8 +31,7 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co Short: "Reopen a pull request", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if len(args) > 0 { opts.SelectorArg = args[0] @@ -54,27 +50,31 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co func reopenRun(opts *ReopenOptions) error { cs := opts.IO.ColorScheme() - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"id", "number", "state", "title"}, } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg) + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } if pr.State == "MERGED" { - fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged", cs.Red("!"), pr.Number, pr.Title) + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged\n", cs.FailureIcon(), pr.Number, pr.Title) return cmdutil.SilentError } if pr.IsOpen() { - fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", cs.Yellow("!"), pr.Number, pr.Title) + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", cs.WarningIcon(), pr.Number, pr.Title) return nil } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + err = api.PullRequestReopen(apiClient, baseRepo, pr) if err != nil { return fmt.Errorf("API call failed: %w", err) diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go index 9c94f11b8..f04db2c06 100644 --- a/pkg/cmd/pr/reopen/reopen_test.go +++ b/pkg/cmd/pr/reopen/reopen_test.go @@ -4,11 +4,11 @@ import ( "bytes" "io/ioutil" "net/http" - "regexp" "testing" - "github.com/cli/cli/internal/config" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -28,12 +28,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, } cmd := NewCmdReopen(factory, nil) @@ -59,13 +53,13 @@ func TestPRReopen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "id": "THE-ID", "number": 666, "title": "The title of the PR", "state": "CLOSED" } - } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{ + ID: "THE-ID", + Number: 123, + State: "CLOSED", + Title: "The title of the PR", + }, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.GraphQL(`mutation PullRequestReopen\b`), httpmock.GraphQLMutation(`{"id": "THE-ID"}`, @@ -74,95 +68,42 @@ func TestPRReopen(t *testing.T) { }), ) - output, err := runCommand(http, true, "666") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen_BranchArg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { - "nodes": [ - { "id": "THE-ID", "number": 666, "title": "The title of the PR", "headRefName": "fix-bug", "state": "CLOSED" } - ] - } } } }`), - ) - http.Register( - httpmock.GraphQL(`mutation PullRequestReopen\b`), - httpmock.GraphQLMutation(`{"id": "THE-ID"}`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["pullRequestId"], "THE-ID") - }), - ) - - output, err := runCommand(http, true, "fix-bug") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "123") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Reopened pull request #123 (The title of the PR)\n", output.Stderr()) } func TestPRReopen_alreadyOpen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "state": "OPEN" } - } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{ + ID: "THE-ID", + Number: 123, + State: "OPEN", + Title: "The title of the PR", + }, ghrepo.New("OWNER", "REPO")) - output, err := runCommand(http, true, "666") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) is already open`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "123") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "! Pull request #123 (The title of the PR) is already open\n", output.Stderr()) } func TestPRReopen_alreadyMerged(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "state": "MERGED"} - } } }`), - ) + shared.RunCommandFinder("123", &api.PullRequest{ + ID: "THE-ID", + Number: 123, + State: "MERGED", + Title: "The title of the PR", + }, ghrepo.New("OWNER", "REPO")) - output, err := runCommand(http, true, "666") - if err == nil { - t.Fatalf("expected an error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) can't be reopened because it was already merged`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + output, err := runCommand(http, true, "123") + assert.EqualError(t, err, "SilentError") + assert.Equal(t, "", output.String()) + assert.Equal(t, "X Pull request #123 (The title of the PR) can't be reopened because it was already merged\n", output.Stderr()) } diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 1ff213bcb..06452149f 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -8,9 +8,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -24,9 +22,8 @@ type ReviewOptions struct { HttpClient func() (*http.Client, error) Config func() (config.Config, error) IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) - Branch func() (string, error) + + Finder shared.PRFinder SelectorArg string InteractiveMode bool @@ -39,8 +36,6 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, - Remotes: f.Remotes, - Branch: f.Branch, } var ( @@ -74,8 +69,7 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} @@ -151,13 +145,11 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co } func reviewRun(opts *ReviewOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"id", "number"}, } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } @@ -183,6 +175,12 @@ func reviewRun(opts *ReviewOptions) error { } } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + err = api.AddReview(apiClient, baseRepo, pr, reviewData) if err != nil { return fmt.Errorf("failed to create review: %w", err) diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index 84d526368..de65e471c 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -6,13 +6,14 @@ import ( "io/ioutil" "net/http" "path/filepath" - "regexp" "testing" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" "github.com/cli/cli/context" - "github.com/cli/cli/git" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -178,24 +179,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Remotes: func() (context.Remotes, error) { - if remotes == nil { - return context.Remotes{ - { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, nil - } - - return remotes, nil - }, - Branch: func() (string, error) { - return "feature", nil - }, } cmd := NewCmdReview(factory, nil) @@ -217,219 +200,67 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s }, err } -func TestPRReview_url_arg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "foobar123", - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO", - "defaultBranchRef": { - "name": "master" - } - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } }`), - ) - http.Register( - httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), - httpmock.GraphQLMutation(`{"data": {} }`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["pullRequestId"], "foobar123") - assert.Equal(t, inputs["event"], "APPROVE") - assert.Equal(t, inputs["body"], "") - }), - ) - - output, err := runCommand(http, nil, true, "--approve https://github.com/OWNER/REPO/pull/123") - if err != nil { - t.Fatalf("error running pr review: %s", err) - } - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Approved pull request #123") -} - -func TestPRReview_number_arg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "foobar123", - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO", - "defaultBranchRef": { - "name": "master" - } - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } `), - ) - http.Register( - httpmock.GraphQL(`mutation PullRequestReviewAdd`), - httpmock.GraphQLMutation(`{"data": {} }`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["pullRequestId"], "foobar123") - assert.Equal(t, inputs["event"], "APPROVE") - assert.Equal(t, inputs["body"], "") - }), - ) - - output, err := runCommand(http, nil, true, "--approve 123") - if err != nil { - t.Fatalf("error running pr review: %s", err) - } - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Approved pull request #123") -} - -func TestPRReview_no_arg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) - http.Register( - httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), - httpmock.GraphQLMutation(`{"data": {} }`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["pullRequestId"], "foobar123") - assert.Equal(t, inputs["event"], "COMMENT") - assert.Equal(t, inputs["body"], "cool story") - }), - ) - - output, err := runCommand(http, nil, true, `--comment -b "cool story"`) - if err != nil { - t.Fatalf("error running pr review: %s", err) - } - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123") -} - func TestPRReview(t *testing.T) { - type c struct { - Cmd string - ExpectedEvent string - ExpectedBody string - } - cases := []c{ - {`--request-changes -b"bad"`, "REQUEST_CHANGES", "bad"}, - {`--approve`, "APPROVE", ""}, - {`--approve -b"hot damn"`, "APPROVE", "hot damn"}, - {`--comment --body "i dunno"`, "COMMENT", "i dunno"}, + tests := []struct { + args string + wantEvent string + wantBody string + }{ + { + args: `--request-changes -b"bad"`, + wantEvent: "REQUEST_CHANGES", + wantBody: "bad", + }, + { + args: `--approve`, + wantEvent: "APPROVE", + wantBody: "", + }, + { + args: `--approve -b"hot damn"`, + wantEvent: "APPROVE", + wantBody: "hot damn", + }, + { + args: `--comment --body "i dunno"`, + wantEvent: "COMMENT", + wantBody: "i dunno", + }, } - for _, kase := range cases { - t.Run(kase.Cmd, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.args, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID"}, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), httpmock.GraphQLMutation(`{"data": {} }`, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["event"], kase.ExpectedEvent) - assert.Equal(t, inputs["body"], kase.ExpectedBody) + assert.Equal(t, map[string]interface{}{ + "pullRequestId": "THE-ID", + "event": tt.wantEvent, + "body": tt.wantBody, + }, inputs) }), ) - _, err := runCommand(http, nil, false, kase.Cmd) - if err != nil { - t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err) - } + output, err := runCommand(http, nil, false, tt.args) + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) }) } } -func TestPRReview_nontty(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) - http.Register( - httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), - httpmock.GraphQLMutation(`{"data": {} }`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["event"], "COMMENT") - assert.Equal(t, inputs["body"], "cool") - }), - ) - - output, err := runCommand(http, nil, false, "-c -bcool") - if err != nil { - t.Fatalf("unexpected error running command: %s", err) - } - - assert.Equal(t, "", output.String()) - assert.Equal(t, "", output.Stderr()) -} - func TestPRReview_interactive(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), httpmock.GraphQLMutation(`{"data": {} }`, @@ -462,33 +293,21 @@ func TestPRReview_interactive(t *testing.T) { }) output, err := runCommand(http, nil, true, "") - if err != nil { - t.Fatalf("got unexpected error running pr review: %s", err) - } + assert.NoError(t, err) + assert.Equal(t, heredoc.Doc(` + Got: - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.String(), - "Got:", - "cool.*story") + cool story + + `), output.String()) + assert.Equal(t, "✓ Approved pull request #123\n", output.Stderr()) } func TestPRReview_interactive_no_body(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) as, teardown := prompt.InitAskStubber() defer teardown() @@ -520,17 +339,8 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`), - ) + shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) + http.Register( httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), httpmock.GraphQLMutation(`{"data": {} }`, @@ -563,15 +373,7 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { }) output, err := runCommand(http, nil, true, "") - if err != nil { - t.Fatalf("got unexpected error running pr review: %s", err) - } - - unexpect := regexp.MustCompile("Got:") - if unexpect.MatchString(output.String()) { - t.Errorf("did not expect to see body printed in %s", output.String()) - } - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Approved pull request #123") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Approved pull request #123\n", output.Stderr()) } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go new file mode 100644 index 000000000..7b90e146c --- /dev/null +++ b/pkg/cmd/pr/shared/finder.go @@ -0,0 +1,532 @@ +package shared + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/cli/cli/api" + remotes "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/set" + "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" + "golang.org/x/sync/errgroup" +) + +type PRFinder interface { + Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) +} + +type progressIndicator interface { + StartProgressIndicator() + StopProgressIndicator() +} + +type finder struct { + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + remotesFn func() (remotes.Remotes, error) + httpClient func() (*http.Client, error) + branchConfig func(string) git.BranchConfig + progress progressIndicator + + repo ghrepo.Interface + prNumber int + branchName string +} + +func NewFinder(factory *cmdutil.Factory) PRFinder { + if runCommandFinder != nil { + f := runCommandFinder + runCommandFinder = &mockFinder{err: errors.New("you must use a RunCommandFinder to stub PR lookups")} + return f + } + + return &finder{ + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + remotesFn: factory.Remotes, + httpClient: factory.HttpClient, + progress: factory.IOStreams, + branchConfig: git.ReadBranchConfig, + } +} + +var runCommandFinder PRFinder + +// RunCommandFinder is the NewMockFinder substitute to be used ONLY in runCommand-style tests. +func RunCommandFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder { + finder := NewMockFinder(selector, pr, repo) + runCommandFinder = finder + return finder +} + +type FindOptions struct { + // Selector can be a number with optional `#` prefix, a branch name with optional `:` prefix, or + // a PR URL. + Selector string + // Fields lists the GraphQL fields to fetch for the PullRequest. + Fields []string + // BaseBranch is the name of the base branch to scope the PR-for-branch lookup to. + BaseBranch string + // States lists the possible PR states to scope the PR-for-branch lookup to. + States []string +} + +func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) { + if len(opts.Fields) == 0 { + return nil, nil, errors.New("Find error: no fields specified") + } + + if repo, prNumber, err := f.parseURL(opts.Selector); err == nil { + f.prNumber = prNumber + f.repo = repo + } + + if f.repo == nil { + repo, err := f.baseRepoFn() + if err != nil { + return nil, nil, fmt.Errorf("could not determine base repo: %w", err) + } + f.repo = repo + } + + if opts.Selector == "" { + if branch, prNumber, err := f.parseCurrentBranch(); err != nil { + return nil, nil, err + } else if prNumber > 0 { + f.prNumber = prNumber + } else { + f.branchName = branch + } + } else if f.prNumber == 0 { + if prNumber, err := strconv.Atoi(strings.TrimPrefix(opts.Selector, "#")); err == nil { + f.prNumber = prNumber + } else { + f.branchName = opts.Selector + } + } + + httpClient, err := f.httpClient() + if err != nil { + return nil, nil, err + } + + if f.progress != nil { + f.progress.StartProgressIndicator() + defer f.progress.StopProgressIndicator() + } + + fields := set.NewStringSet() + fields.AddValues(opts.Fields) + numberFieldOnly := fields.Len() == 1 && fields.Contains("number") + fields.Add("id") // for additional preload queries below + + var pr *api.PullRequest + if f.prNumber > 0 { + if numberFieldOnly { + // avoid hitting the API if we already have all the information + return &api.PullRequest{Number: f.prNumber}, f.repo, nil + } + pr, err = findByNumber(httpClient, f.repo, f.prNumber, fields.ToSlice()) + } else { + pr, err = findForBranch(httpClient, f.repo, opts.BaseBranch, f.branchName, opts.States, fields.ToSlice()) + } + if err != nil { + return pr, f.repo, err + } + + g, _ := errgroup.WithContext(context.Background()) + if fields.Contains("reviews") { + g.Go(func() error { + return preloadPrReviews(httpClient, f.repo, pr) + }) + } + if fields.Contains("comments") { + g.Go(func() error { + return preloadPrComments(httpClient, f.repo, pr) + }) + } + if fields.Contains("statusCheckRollup") { + g.Go(func() error { + return preloadPrChecks(httpClient, f.repo, pr) + }) + } + + return pr, f.repo, g.Wait() +} + +var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) + +func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) { + if prURL == "" { + return nil, 0, fmt.Errorf("invalid URL: %q", prURL) + } + + u, err := url.Parse(prURL) + if err != nil { + return nil, 0, err + } + + if u.Scheme != "https" && u.Scheme != "http" { + return nil, 0, fmt.Errorf("invalid scheme: %s", u.Scheme) + } + + m := pullURLRE.FindStringSubmatch(u.Path) + if m == nil { + return nil, 0, fmt.Errorf("not a pull request URL: %s", prURL) + } + + repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) + prNumber, _ := strconv.Atoi(m[3]) + return repo, prNumber, nil +} + +var prHeadRE = regexp.MustCompile(`^refs/pull/(\d+)/head$`) + +func (f *finder) parseCurrentBranch() (string, int, error) { + prHeadRef, err := f.branchFn() + if err != nil { + return "", 0, err + } + + branchConfig := f.branchConfig(prHeadRef) + + // the branch is configured to merge a special PR head ref + if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { + prNumber, _ := strconv.Atoi(m[1]) + return "", prNumber, nil + } + + var branchOwner string + if branchConfig.RemoteURL != nil { + // the branch merges from a remote specified by URL + if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { + branchOwner = r.RepoOwner() + } + } else if branchConfig.RemoteName != "" { + // the branch merges from a remote specified by name + rem, _ := f.remotesFn() + if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { + branchOwner = r.RepoOwner() + } + } + + if branchOwner != "" { + if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { + prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") + } + // prepend `OWNER:` if this branch is pushed to a fork + if !strings.EqualFold(branchOwner, f.repo.RepoOwner()) { + prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) + } + } + + return prHeadRef, 0, nil +} + +func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) { + type response struct { + Repository struct { + PullRequest api.PullRequest + } + } + + query := fmt.Sprintf(` + query PullRequestByNumber($owner: String!, $repo: String!, $pr_number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr_number) {%s} + } + }`, api.PullRequestGraphQL(fields)) + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "pr_number": number, + } + + var resp response + client := api.NewClientFromHTTP(httpClient) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) + if err != nil { + return nil, err + } + + return &resp.Repository.PullRequest, nil +} + +func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters, fields []string) (*api.PullRequest, error) { + type response struct { + Repository struct { + PullRequests struct { + Nodes []api.PullRequest + } + } + } + + fieldSet := set.NewStringSet() + fieldSet.AddValues(fields) + // these fields are required for filtering below + fieldSet.AddValues([]string{"state", "baseRefName", "headRefName", "isCrossRepository", "headRepositoryOwner"}) + + query := fmt.Sprintf(` + query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) { + repository(owner: $owner, name: $repo) { + pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes {%s} + } + } + }`, api.PullRequestGraphQL(fieldSet.ToSlice())) + + branchWithoutOwner := headBranch + if idx := strings.Index(headBranch, ":"); idx >= 0 { + branchWithoutOwner = headBranch[idx+1:] + } + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "headRefName": branchWithoutOwner, + "states": stateFilters, + } + + var resp response + client := api.NewClientFromHTTP(httpClient) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) + if err != nil { + return nil, err + } + + prs := resp.Repository.PullRequests.Nodes + sort.SliceStable(prs, func(a, b int) bool { + return prs[a].State == "OPEN" && prs[b].State != "OPEN" + }) + + for _, pr := range prs { + if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) { + return &pr, nil + } + } + + return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)} +} + +func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { + if !pr.Reviews.PageInfo.HasNextPage { + return nil + } + + type response struct { + Node struct { + PullRequest struct { + Reviews api.PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"` + } `graphql:"...on PullRequest"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(pr.ID), + "endCursor": githubv4.String(pr.Reviews.PageInfo.EndCursor), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient) + + for { + var query response + err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables) + if err != nil { + return err + } + + pr.Reviews.Nodes = append(pr.Reviews.Nodes, query.Node.PullRequest.Reviews.Nodes...) + pr.Reviews.TotalCount = len(pr.Reviews.Nodes) + + if !query.Node.PullRequest.Reviews.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Node.PullRequest.Reviews.PageInfo.EndCursor) + } + + pr.Reviews.PageInfo.HasNextPage = false + return nil +} + +func preloadPrComments(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { + if !pr.Comments.PageInfo.HasNextPage { + return nil + } + + type response struct { + Node struct { + PullRequest struct { + Comments api.Comments `graphql:"comments(first: 100, after: $endCursor)"` + } `graphql:"...on PullRequest"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(pr.ID), + "endCursor": githubv4.String(pr.Comments.PageInfo.EndCursor), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client) + + for { + var query response + err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables) + if err != nil { + return err + } + + pr.Comments.Nodes = append(pr.Comments.Nodes, query.Node.PullRequest.Comments.Nodes...) + pr.Comments.TotalCount = len(pr.Comments.Nodes) + + if !query.Node.PullRequest.Comments.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Node.PullRequest.Comments.PageInfo.EndCursor) + } + + pr.Comments.PageInfo.HasNextPage = false + return nil +} + +func preloadPrChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { + if len(pr.StatusCheckRollup.Nodes) == 0 { + return nil + } + statusCheckRollup := &pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts + if !statusCheckRollup.PageInfo.HasNextPage { + return nil + } + + endCursor := statusCheckRollup.PageInfo.EndCursor + + type response struct { + Node *api.PullRequest + } + + query := fmt.Sprintf(` + query PullRequestStatusChecks($id: ID!, $endCursor: String!) { + node(id: $id) { + ...on PullRequest { + %s + } + } + }`, api.StatusCheckRollupGraphQL("$endCursor")) + + variables := map[string]interface{}{ + "id": pr.ID, + } + + apiClient := api.NewClientFromHTTP(client) + for { + variables["endCursor"] = endCursor + var resp response + err := apiClient.GraphQL(repo.RepoHost(), query, variables, &resp) + if err != nil { + return err + } + + result := resp.Node.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts + statusCheckRollup.Nodes = append( + statusCheckRollup.Nodes, + result.Nodes..., + ) + + if !result.PageInfo.HasNextPage { + break + } + endCursor = result.PageInfo.EndCursor + } + + statusCheckRollup.PageInfo.HasNextPage = false + return nil +} + +type NotFoundError struct { + error +} + +func (err *NotFoundError) Unwrap() error { + return err.error +} + +func NewMockFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder { + var err error + if pr == nil { + err = &NotFoundError{errors.New("no pull requests found")} + } + return &mockFinder{ + expectSelector: selector, + pr: pr, + repo: repo, + err: err, + } +} + +type mockFinder struct { + called bool + expectSelector string + expectFields []string + pr *api.PullRequest + repo ghrepo.Interface + err error +} + +func (m *mockFinder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) { + if m.err != nil { + return nil, nil, m.err + } + if m.expectSelector != opts.Selector { + return nil, nil, fmt.Errorf("mockFinder: expected selector %q, got %q", m.expectSelector, opts.Selector) + } + if len(m.expectFields) > 0 && !isEqualSet(m.expectFields, opts.Fields) { + return nil, nil, fmt.Errorf("mockFinder: expected fields %v, got %v", m.expectFields, opts.Fields) + } + if m.called { + return nil, nil, errors.New("mockFinder used more than once") + } + m.called = true + + if m.pr.HeadRepositoryOwner.Login == "" { + // pose as same-repo PR by default + m.pr.HeadRepositoryOwner.Login = m.repo.RepoOwner() + } + + return m.pr, m.repo, nil +} + +func (m *mockFinder) ExpectFields(fields []string) { + m.expectFields = fields +} + +func isEqualSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + + aCopy := make([]string, len(a)) + copy(aCopy, a) + bCopy := make([]string, len(b)) + copy(bCopy, b) + sort.Strings(aCopy) + sort.Strings(bCopy) + + for i := range aCopy { + if aCopy[i] != bCopy[i] { + return false + } + } + return true +} diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go new file mode 100644 index 000000000..488e470ff --- /dev/null +++ b/pkg/cmd/pr/shared/finder_test.go @@ -0,0 +1,394 @@ +package shared + +import ( + "errors" + "net/http" + "net/url" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/httpmock" +) + +func TestFind(t *testing.T) { + type args struct { + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + branchConfig func(string) git.BranchConfig + remotesFn func() (context.Remotes, error) + selector string + fields []string + baseBranch string + } + tests := []struct { + name string + args args + httpStub func(*httpmock.Registry) + wantPR int + wantRepo string + wantErr bool + }{ + { + name: "number argument", + args: args{ + selector: "13", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "baseRepo is error", + args: args{ + selector: "13", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return nil, errors.New("baseRepoErr") + }, + }, + wantErr: true, + }, + { + name: "blank fields is error", + args: args{ + selector: "13", + fields: []string{}, + }, + wantErr: true, + }, + { + name: "number only", + args: args{ + selector: "13", + fields: []string{"number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + }, + httpStub: nil, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "number with hash argument", + args: args{ + selector: "#13", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "URL argument", + args: args{ + selector: "https://example.org/OWNER/REPO/pull/13/files", + fields: []string{"id", "number"}, + baseRepoFn: nil, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://example.org/OWNER/REPO", + }, + { + name: "branch argument", + args: args{ + selector: "blueberries", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequests":{"nodes":[ + { + "number": 14, + "state": "CLOSED", + "baseRefName": "main", + "headRefName": "blueberries", + "isCrossRepository": false, + "headRepositoryOwner": {"login":"OWNER"} + }, + { + "number": 13, + "state": "OPEN", + "baseRefName": "main", + "headRefName": "blueberries", + "isCrossRepository": false, + "headRepositoryOwner": {"login":"OWNER"} + } + ]} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "branch argument with base branch", + args: args{ + selector: "blueberries", + baseBranch: "main", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequests":{"nodes":[ + { + "number": 14, + "state": "OPEN", + "baseRefName": "dev", + "headRefName": "blueberries", + "isCrossRepository": false, + "headRepositoryOwner": {"login":"OWNER"} + }, + { + "number": 13, + "state": "OPEN", + "baseRefName": "main", + "headRefName": "blueberries", + "isCrossRepository": false, + "headRepositoryOwner": {"login":"OWNER"} + } + ]} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "no argument reads current branch", + args: args{ + selector: "", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: func(branch string) (c git.BranchConfig) { + return + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequests":{"nodes":[ + { + "number": 13, + "state": "OPEN", + "baseRefName": "main", + "headRefName": "blueberries", + "isCrossRepository": false, + "headRepositoryOwner": {"login":"OWNER"} + } + ]} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "current branch is error", + args: args{ + selector: "", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "", errors.New("branchErr") + }, + }, + wantErr: true, + }, + { + name: "current branch with upstream configuration", + args: args{ + selector: "", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: func(branch string) (c git.BranchConfig) { + c.MergeRef = "refs/heads/blue-upstream-berries" + c.RemoteName = "origin" + return + }, + remotesFn: func() (context.Remotes, error) { + return context.Remotes{{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("UPSTREAMOWNER", "REPO"), + }}, nil + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequests":{"nodes":[ + { + "number": 13, + "state": "OPEN", + "baseRefName": "main", + "headRefName": "blue-upstream-berries", + "isCrossRepository": true, + "headRepositoryOwner": {"login":"UPSTREAMOWNER"} + } + ]} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "current branch with upstream configuration", + args: args{ + selector: "", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: func(branch string) (c git.BranchConfig) { + u, _ := url.Parse("https://github.com/UPSTREAMOWNER/REPO") + c.MergeRef = "refs/heads/blue-upstream-berries" + c.RemoteURL = u + return + }, + remotesFn: nil, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequests":{"nodes":[ + { + "number": 13, + "state": "OPEN", + "baseRefName": "main", + "headRefName": "blue-upstream-berries", + "isCrossRepository": true, + "headRepositoryOwner": {"login":"UPSTREAMOWNER"} + } + ]} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + { + name: "current branch made by pr checkout", + args: args{ + selector: "", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: func(branch string) (c git.BranchConfig) { + c.MergeRef = "refs/pull/13/head" + return + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStub != nil { + tt.httpStub(reg) + } + + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + baseRepoFn: tt.args.baseRepoFn, + branchFn: tt.args.branchFn, + branchConfig: tt.args.branchConfig, + remotesFn: tt.args.remotesFn, + } + + pr, repo, err := f.Find(FindOptions{ + Selector: tt.args.selector, + Fields: tt.args.fields, + BaseBranch: tt.args.baseBranch, + }) + if (err != nil) != tt.wantErr { + t.Errorf("Find() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if tt.wantPR > 0 { + t.Error("wantPR field is not checked in error case") + } + if tt.wantRepo != "" { + t.Error("wantRepo field is not checked in error case") + } + return + } + + if pr.Number != tt.wantPR { + t.Errorf("want pr #%d, got #%d", tt.wantPR, pr.Number) + } + repoURL := ghrepo.GenerateRepoURL(repo, "") + if repoURL != tt.wantRepo { + t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL) + } + }) + } +} diff --git a/pkg/cmd/pr/shared/lookup.go b/pkg/cmd/pr/shared/lookup.go deleted file mode 100644 index 06e9221c0..000000000 --- a/pkg/cmd/pr/shared/lookup.go +++ /dev/null @@ -1,121 +0,0 @@ -package shared - -import ( - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - - "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/ghrepo" -) - -// PRFromArgs looks up the pull request from either the number/branch/URL argument or one belonging to the current branch -// -// NOTE: this API isn't great, but is here as a compatibility layer between old-style and new-style commands -func PRFromArgs(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), branchFn func() (string, error), remotesFn func() (context.Remotes, error), arg string) (*api.PullRequest, ghrepo.Interface, error) { - if arg != "" { - // First check to see if the prString is a url, return repo from url if found. This - // is run first because we don't need to run determineBaseRepo for this path - pr, r, err := prFromURL(apiClient, arg) - if pr != nil || err != nil { - return pr, r, err - } - } - - repo, err := baseRepoFn() - if err != nil { - return nil, nil, fmt.Errorf("could not determine base repo: %w", err) - } - - // If there are no args see if we can guess the PR from the current branch - if arg == "" { - pr, err := prForCurrentBranch(apiClient, repo, branchFn, remotesFn) - return pr, repo, err - } else { - // Next see if the prString is a number and use that to look up the url - pr, err := prFromNumberString(apiClient, repo, arg) - if pr != nil || err != nil { - return pr, repo, err - } - - // Last see if it is a branch name - pr, err = api.PullRequestForBranch(apiClient, repo, "", arg, nil) - return pr, repo, err - } -} - -func prFromNumberString(apiClient *api.Client, repo ghrepo.Interface, s string) (*api.PullRequest, error) { - if prNumber, err := strconv.Atoi(strings.TrimPrefix(s, "#")); err == nil { - return api.PullRequestByNumber(apiClient, repo, prNumber) - } - - return nil, nil -} - -var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) - -func prFromURL(apiClient *api.Client, s string) (*api.PullRequest, ghrepo.Interface, error) { - u, err := url.Parse(s) - if err != nil { - return nil, nil, nil - } - - if u.Scheme != "https" && u.Scheme != "http" { - return nil, nil, nil - } - - m := pullURLRE.FindStringSubmatch(u.Path) - if m == nil { - return nil, nil, nil - } - - repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) - prNumberString := m[3] - pr, err := prFromNumberString(apiClient, repo, prNumberString) - return pr, repo, err -} - -func prForCurrentBranch(apiClient *api.Client, repo ghrepo.Interface, branchFn func() (string, error), remotesFn func() (context.Remotes, error)) (*api.PullRequest, error) { - prHeadRef, err := branchFn() - if err != nil { - return nil, err - } - - branchConfig := git.ReadBranchConfig(prHeadRef) - - // the branch is configured to merge a special PR head ref - prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) - if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - return prFromNumberString(apiClient, repo, m[1]) - } - - var branchOwner string - if branchConfig.RemoteURL != nil { - // the branch merges from a remote specified by URL - if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - branchOwner = r.RepoOwner() - } - } else if branchConfig.RemoteName != "" { - // the branch merges from a remote specified by name - rem, _ := remotesFn() - if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { - branchOwner = r.RepoOwner() - } - } - - if branchOwner != "" { - if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { - prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") - } - // prepend `OWNER:` if this branch is pushed to a fork - if !strings.EqualFold(branchOwner, repo.RepoOwner()) { - prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) - } - } - - return api.PullRequestForBranch(apiClient, repo, "", prHeadRef, nil) -} diff --git a/pkg/cmd/pr/shared/lookup_test.go b/pkg/cmd/pr/shared/lookup_test.go deleted file mode 100644 index 4d843d7ae..000000000 --- a/pkg/cmd/pr/shared/lookup_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package shared - -import ( - "net/http" - "testing" - - "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/httpmock" -) - -func TestPRFromArgs(t *testing.T) { - type args struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - remotesFn func() (context.Remotes, error) - selector string - } - tests := []struct { - name string - args args - httpStub func(*httpmock.Registry) - wantPR int - wantRepo string - wantErr bool - }{ - { - name: "number argument", - args: args{ - selector: "13", - baseRepoFn: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("OWNER/REPO") - }, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "pullRequest":{"number":13} - }}}`)) - }, - wantPR: 13, - wantRepo: "https://github.com/OWNER/REPO", - }, - { - name: "number with hash argument", - args: args{ - selector: "#13", - baseRepoFn: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("OWNER/REPO") - }, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "pullRequest":{"number":13} - }}}`)) - }, - wantPR: 13, - wantRepo: "https://github.com/OWNER/REPO", - }, - { - name: "URL argument", - args: args{ - selector: "https://example.org/OWNER/REPO/pull/13/files", - baseRepoFn: nil, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query PullRequest_fields\b`), - httpmock.StringResponse(`{"data":{}}`)) - r.Register( - httpmock.GraphQL(`query PullRequest_fields2\b`), - httpmock.StringResponse(`{"data":{}}`)) - r.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "pullRequest":{"number":13} - }}}`)) - }, - wantPR: 13, - wantRepo: "https://example.org/OWNER/REPO", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reg := &httpmock.Registry{} - if tt.httpStub != nil { - tt.httpStub(reg) - } - httpClient := &http.Client{Transport: reg} - pr, repo, err := PRFromArgs(api.NewClientFromHTTP(httpClient), tt.args.baseRepoFn, tt.args.branchFn, tt.args.remotesFn, tt.args.selector) - if (err != nil) != tt.wantErr { - t.Errorf("IssueFromArg() error = %v, wantErr %v", err, tt.wantErr) - return - } - if pr.Number != tt.wantPR { - t.Errorf("want issue #%d, got #%d", tt.wantPR, pr.Number) - } - repoURL := ghrepo.GenerateRepoURL(repo, "") - if repoURL != tt.wantRepo { - t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL) - } - }) - } -} diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go index 892973f26..6b6e348d0 100644 --- a/pkg/cmd/pr/shared/preserve_test.go +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "io/ioutil" - "os" "testing" "github.com/cli/cli/pkg/iostreams" @@ -70,6 +69,8 @@ func Test_PreserveInput(t *testing.T) { }, } + tempDir := t.TempDir() + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.state == nil { @@ -78,9 +79,9 @@ func Test_PreserveInput(t *testing.T) { io, _, _, errOut := iostreams.Test() - tf, tferr := tmpfile() + tf, tferr := ioutil.TempFile(tempDir, "testfile*") assert.NoError(t, tferr) - defer os.Remove(tf.Name()) + defer tf.Close() io.TempFileOverride = tf @@ -111,13 +112,3 @@ func Test_PreserveInput(t *testing.T) { }) } } - -func tmpfile() (*os.File, error) { - dir := os.TempDir() - tmpfile, err := ioutil.TempFile(dir, "testfile*") - if err != nil { - return nil, err - } - - return tmpfile, nil -} diff --git a/pkg/cmd/pr/status/fixtures/prStatusChecks.json b/pkg/cmd/pr/status/fixtures/prStatusChecks.json index 55035ae36..dd1605b1d 100644 --- a/pkg/cmd/pr/status/fixtures/prStatusChecks.json +++ b/pkg/cmd/pr/status/fixtures/prStatusChecks.json @@ -17,7 +17,7 @@ "url": "https://github.com/cli/cli/pull/8", "headRefName": "strawberries", "reviewDecision": "CHANGES_REQUESTED", - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { @@ -44,7 +44,7 @@ "url": "https://github.com/cli/cli/pull/7", "headRefName": "banananana", "reviewDecision": "APPROVED", - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { @@ -72,7 +72,7 @@ "url": "https://github.com/cli/cli/pull/6", "headRefName": "avo", "reviewDecision": "REVIEW_REQUIRED", - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 115ee035b..d14ae5ec2 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -113,13 +113,13 @@ func statusRun(opts *StatusOptions) error { if opts.Exporter != nil { data := map[string]interface{}{ "currentBranch": nil, - "createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Exporter.Fields()), - "needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Exporter.Fields()), + "createdBy": prPayload.ViewerCreated.PullRequests, + "needsReview": prPayload.ReviewRequested.PullRequests, } if prPayload.CurrentPR != nil { - data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Exporter.Fields()) + data["currentBranch"] = prPayload.CurrentPR } - return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled()) } out := opts.IO.Out diff --git a/pkg/cmd/pr/view/fixtures/prView.json b/pkg/cmd/pr/view/fixtures/prView.json deleted file mode 100644 index c15a828a6..000000000 --- a/pkg/cmd/pr/view/fixtures/prView.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "data": { - "repository": { - "pullRequests": { - "nodes": [ - { - "number": 12, - "title": "Blueberries are from a fork", - "state": "OPEN", - "body": "yeah", - "url": "https://github.com/OWNER/REPO/pull/12", - "headRefName": "blueberries", - "baseRefName": "master", - "headRepositoryOwner": { - "login": "hubot" - }, - "additions": 100, - "deletions": 10, - "commits": { - "totalCount": 12 - }, - "author": { - "login": "nobody" - }, - "isCrossRepository": true, - "isDraft": false - }, - { - "number": 10, - "title": "Blueberries are a good fruit", - "state": "OPEN", - "body": "**blueberries taste good**", - "url": "https://github.com/OWNER/REPO/pull/10", - "baseRefName": "master", - "headRefName": "blueberries", - "author": { - "login": "nobody" - }, - "additions": 100, - "deletions": 10, - "headRepositoryOwner": { - "login": "OWNER" - }, - "commits": { - "totalCount": 8 - }, - "isCrossRepository": false, - "isDraft": false - } - ] - } - } - } -} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json b/pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json deleted file mode 100644 index 03a55dbc5..000000000 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "data": { - "repository": { - "pullRequests": { - "nodes": [ - { - "number": 12, - "title": "Blueberries are from a fork", - "state": "OPEN", - "body": "yeah", - "url": "https://github.com/OWNER/REPO/pull/12", - "headRefName": "blueberries", - "baseRefName": "master", - "headRepositoryOwner": { - "login": "hubot" - }, - "additions": 100, - "deletions": 10, - "commits": { - "totalCount": 12 - }, - "author": { - "login": "nobody" - }, - "isCrossRepository": true, - "isDraft": false - }, - { - "number": 10, - "title": "Blueberries are a good fruit", - "state": "OPEN", - "body": "**blueberries taste good**", - "url": "https://github.com/OWNER/REPO/pull/10", - "baseRefName": "master", - "headRefName": "blueberries", - "author": { - "login": "nobody" - }, - "headRepositoryOwner": { - "login": "OWNER" - }, - "additions": 100, - "deletions": 10, - "commits": { - "totalCount": 8 - }, - "isCrossRepository": false, - "isDraft": true - } - ] - } - } - } -} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json deleted file mode 100644 index 92e1a5a75..000000000 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json +++ /dev/null @@ -1 +0,0 @@ -{ "data": { "repository": { "pullRequest": { "reviews": { } } } } } diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json deleted file mode 100644 index 9893ac523..000000000 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "data": { - "repository": { - "pullRequests": { - "nodes": [ - { - "id": "PR_12", - "number": 12, - "title": "Blueberries are from a fork", - "state": "OPEN", - "body": "yeah", - "url": "https://github.com/OWNER/REPO/pull/12", - "headRefName": "blueberries", - "baseRefName": "master", - "additions": 100, - "deletions": 10, - "headRepositoryOwner": { - "login": "hubot" - }, - "assignees": { - "nodes": [], - "totalcount": 0 - }, - "labels": { - "nodes": [], - "totalcount": 0 - }, - "projectcards": { - "nodes": [], - "totalcount": 0 - }, - "milestone": {}, - "commits": { - "totalCount": 12 - }, - "author": { - "login": "nobody" - }, - "isCrossRepository": true, - "isDraft": false - }, - { - "id": "PR_10", - "number": 10, - "title": "Blueberries are a good fruit", - "state": "OPEN", - "body": "**blueberries taste good**", - "url": "https://github.com/OWNER/REPO/pull/10", - "baseRefName": "master", - "headRefName": "blueberries", - "author": { - "login": "nobody" - }, - "additions": 100, - "deletions": 10, - "assignees": { - "nodes": [ - { - "login": "marseilles" - }, - { - "login": "monaco" - } - ], - "totalcount": 2 - }, - "labels": { - "nodes": [ - { - "name": "one" - }, - { - "name": "two" - }, - { - "name": "three" - }, - { - "name": "four" - }, - { - "name": "five" - } - ], - "totalcount": 5 - }, - "projectcards": { - "nodes": [ - { - "project": { - "name": "Project 1" - }, - "column": { - "name": "column A" - } - }, - { - "project": { - "name": "Project 2" - }, - "column": { - "name": "column B" - } - }, - { - "project": { - "name": "Project 3" - }, - "column": { - "name": "column C" - } - } - ], - "totalcount": 3 - }, - "milestone": { - "title": "uluru" - }, - "headRepositoryOwner": { - "login": "OWNER" - }, - "commits": { - "nodes": [ - { - "commit": { - "oid": "123456789" - } - } - ], - "totalCount": 8 - }, - "isCrossRepository": false, - "isDraft": false - } - ] - } - } - } -} diff --git a/pkg/cmd/pr/view/fixtures/prView_EmptyBody.json b/pkg/cmd/pr/view/fixtures/prView_EmptyBody.json deleted file mode 100644 index dcc2be64b..000000000 --- a/pkg/cmd/pr/view/fixtures/prView_EmptyBody.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "data": { - "repository": { - "pullRequests": { - "nodes": [ - { - "number": 12, - "title": "Blueberries are from a fork", - "state": "OPEN", - "body": "yeah", - "url": "https://github.com/OWNER/REPO/pull/12", - "headRefName": "blueberries", - "baseRefName": "master", - "headRepositoryOwner": { - "login": "hubot" - }, - "additions": 100, - "deletions": 10, - "commits": { - "totalCount": 12 - }, - "author": { - "login": "nobody" - }, - "isCrossRepository": true - }, - { - "number": 10, - "title": "Blueberries are a good fruit", - "state": "OPEN", - "body": "", - "url": "https://github.com/OWNER/REPO/pull/10", - "baseRefName": "master", - "headRefName": "blueberries", - "additions": 100, - "deletions": 10, - "author": { - "login": "nobody" - }, - "headRepositoryOwner": { - "login": "OWNER" - }, - "commits": { - "totalCount": 8 - }, - "isCrossRepository": false - } - ] - } - } - } -} diff --git a/pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json b/pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json deleted file mode 100644 index 7c1fb0c05..000000000 --- a/pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json +++ /dev/null @@ -1,15 +0,0 @@ -{"data":{ - "repository": { - "pullRequests": { - "edges": [] - } - }, - "viewerCreated": { - "edges": [], - "pageInfo": { "hasNextPage": false } - }, - "reviewRequested": { - "edges": [], - "pageInfo": { "hasNextPage": false } - } -}} \ No newline at end of file diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 4e6300297..91087d74b 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -3,17 +3,12 @@ package view import ( "errors" "fmt" - "net/http" "sort" "strconv" "strings" - "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -27,14 +22,10 @@ type browser interface { } type ViewOptions struct { - HttpClient func() (*http.Client, error) - Config func() (config.Config, error) - IO *iostreams.IOStreams - Browser browser - BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) - Branch func() (string, error) + IO *iostreams.IOStreams + Browser browser + Finder shared.PRFinder Exporter cmdutil.Exporter SelectorArg string @@ -44,12 +35,8 @@ type ViewOptions struct { func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { opts := &ViewOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Config: f.Config, - Remotes: f.Remotes, - Branch: f.Branch, - Browser: f.Browser, + IO: f.IOStreams, + Browser: f.Browser, } cmd := &cobra.Command{ @@ -65,8 +52,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.Finder = shared.NewFinder(f) if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} @@ -90,10 +76,25 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } +var defaultFields = []string{ + "url", "number", "title", "state", "body", "author", + "isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount", + "baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository", + "reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone", + "comments", "reactionGroups", +} + func viewRun(opts *ViewOptions) error { - opts.IO.StartProgressIndicator() - pr, err := retrievePullRequest(opts) - opts.IO.StopProgressIndicator() + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, + Fields: defaultFields, + } + if opts.BrowserMode { + findOptions.Fields = []string{"url"} + } else if opts.Exporter != nil { + findOptions.Fields = opts.Exporter.Fields() + } + pr, _, err := opts.Finder.Find(findOptions) if err != nil { return err } @@ -117,8 +118,7 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() if opts.Exporter != nil { - exportPR := pr.ExportData(opts.Exporter.Fields()) - return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled()) } if connectedToTerminal { @@ -149,7 +149,11 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { fmt.Fprintf(out, "assignees:\t%s\n", assignees) fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) fmt.Fprintf(out, "projects:\t%s\n", projects) - fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title) + var milestoneTitle string + if pr.Milestone != nil { + milestoneTitle = pr.Milestone.Title + } + fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) fmt.Fprintf(out, "number:\t%d\n", pr.Number) fmt.Fprintf(out, "url:\t%s\n", pr.URL) fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions))) @@ -201,7 +205,7 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error { fmt.Fprint(out, cs.Bold("Projects: ")) fmt.Fprintln(out, projects) } - if pr.Milestone.Title != "" { + if pr.Milestone != nil { fmt.Fprint(out, cs.Bold("Milestone: ")) fmt.Fprintln(out, pr.Milestone.Title) } @@ -413,51 +417,3 @@ func prStateWithDraft(pr *api.PullRequest) string { return pr.State } - -func retrievePullRequest(opts *ViewOptions) (*api.PullRequest, error) { - httpClient, err := opts.HttpClient() - if err != nil { - return nil, err - } - - apiClient := api.NewClientFromHTTP(httpClient) - - pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) - if err != nil { - return nil, err - } - - if opts.BrowserMode { - return pr, nil - } - - var errp, errc error - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - var reviews *api.PullRequestReviews - reviews, errp = api.ReviewsForPullRequest(apiClient, repo, pr) - pr.Reviews = *reviews - }() - - if opts.Comments { - wg.Add(1) - go func() { - defer wg.Done() - var comments *api.Comments - comments, errc = api.CommentsForPullRequest(apiClient, repo, pr) - pr.Comments = *comments - }() - } - - wg.Wait() - - if errp != nil { - err = errp - } - if errc != nil { - err = errc - } - return pr, err -} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index cda0e266c..78d588f1e 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -2,16 +2,17 @@ package view import ( "bytes" + "encoding/json" "fmt" "io/ioutil" "net/http" + "os" "testing" - "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/config" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -121,26 +122,6 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t factory := &cmdutil.Factory{ IOStreams: io, Browser: browser, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: rt}, nil - }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Remotes: func() (context.Remotes, error) { - return context.Remotes{ - { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, nil - }, - Branch: func() (string, error) { - return branch, nil - }, } cmd := NewCmdView(factory, nil) @@ -163,6 +144,50 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t }, err } +// hack for compatibility with old JSON fixture files +func prFromFixtures(fixtures map[string]string) (*api.PullRequest, error) { + var response struct { + Data struct { + Repository struct { + PullRequest *api.PullRequest + } + } + } + + ff, err := os.Open(fixtures["PullRequestByNumber"]) + if err != nil { + return nil, err + } + defer ff.Close() + + dec := json.NewDecoder(ff) + err = dec.Decode(&response) + if err != nil { + return nil, err + } + + for name := range fixtures { + switch name { + case "PullRequestByNumber": + case "ReviewsForPullRequest", "CommentsForPullRequest": + ff, err := os.Open(fixtures[name]) + if err != nil { + return nil, err + } + defer ff.Close() + dec := json.NewDecoder(ff) + err = dec.Decode(&response) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unrecognized fixture type: %q", name) + } + } + + return response.Data.Repository.PullRequest, nil +} + func TestPRView_Preview_nontty(t *testing.T) { tests := map[string]struct { branch string @@ -174,8 +199,7 @@ func TestPRView_Preview_nontty(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreview.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreview.json", }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, @@ -197,8 +221,7 @@ func TestPRView_Preview_nontty(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, @@ -231,74 +254,11 @@ func TestPRView_Preview_nontty(t *testing.T) { `\*\*blueberries taste good\*\*`, }, }, - "Open PR with metadata by branch": { - branch: "master", - args: "blueberries", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\tmarseilles, monaco\n`, - `reviewers:\t\n`, - `labels:\tone, two, three, four, five\n`, - `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, - `milestone:\tuluru\n`, - `additions:\t100\n`, - `deletions:\t10\n`, - `blueberries taste good`, - }, - }, - "Open PR for the current branch": { - branch: "blueberries", - args: "", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prView.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\t\n`, - `reviewers:\t\n`, - `labels:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `additions:\t100\n`, - `deletions:\t10\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR wth empty body for the current branch": { - branch: "blueberries", - args: "", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prView_EmptyBody.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\t\n`, - `reviewers:\t\n`, - `labels:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `additions:\t100\n`, - `deletions:\t10\n`, - }, - }, "Closed PR": { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", }, expectedOutputs: []string{ `state:\tCLOSED\n`, @@ -317,8 +277,7 @@ func TestPRView_Preview_nontty(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", }, expectedOutputs: []string{ `state:\tMERGED\n`, @@ -337,8 +296,7 @@ func TestPRView_Preview_nontty(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, @@ -354,37 +312,16 @@ func TestPRView_Preview_nontty(t *testing.T) { `\*\*blueberries taste good\*\*`, }, }, - "Draft PR by branch": { - branch: "master", - args: "blueberries", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit\n`, - `state:\tDRAFT\n`, - `author:\tnobody\n`, - `labels:`, - `assignees:`, - `reviewers:`, - `projects:`, - `milestone:`, - `additions:\t100\n`, - `deletions:\t10\n`, - `\*\*blueberries taste good\*\*`, - }, - }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - for name, file := range tc.fixtures { - name := fmt.Sprintf(`query %s\b`, name) - http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) - } + + pr, err := prFromFixtures(tc.fixtures) + require.NoError(t, err) + shared.RunCommandFinder("12", pr, ghrepo.New("OWNER", "REPO")) output, err := runCommand(http, tc.branch, false, tc.args) if err != nil { @@ -410,8 +347,7 @@ func TestPRView_Preview(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreview.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreview.json", }, expectedOutputs: []string{ `Blueberries are from a fork`, @@ -424,8 +360,7 @@ func TestPRView_Preview(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", }, expectedOutputs: []string{ `Blueberries are from a fork`, @@ -453,57 +388,11 @@ func TestPRView_Preview(t *testing.T) { `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, }, - "Open PR with metadata by branch": { - branch: "master", - args: "blueberries", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries.+100.-10`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, - `Milestone:.*uluru\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - "Open PR for the current branch": { - branch: "blueberries", - args: "", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prView.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries.+100.-10`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - "Open PR wth empty body for the current branch": { - branch: "blueberries", - args: "", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prView_EmptyBody.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries.+100.-10`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, "Closed PR": { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", }, expectedOutputs: []string{ `Blueberries are from a fork`, @@ -516,8 +405,7 @@ func TestPRView_Preview(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", }, expectedOutputs: []string{ `Blueberries are from a fork`, @@ -530,8 +418,7 @@ func TestPRView_Preview(t *testing.T) { branch: "master", args: "12", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", }, expectedOutputs: []string{ `Blueberries are from a fork`, @@ -540,30 +427,16 @@ func TestPRView_Preview(t *testing.T) { `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, }, - "Draft PR by branch": { - branch: "master", - args: "blueberries", - fixtures: map[string]string{ - "PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json", - "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", - }, - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Draft.*nobody wants to merge 8 commits into master from blueberries.+100.-10`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - for name, file := range tc.fixtures { - name := fmt.Sprintf(`query %s\b`, name) - http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) - } + + pr, err := prFromFixtures(tc.fixtures) + require.NoError(t, err) + shared.RunCommandFinder("12", pr, ghrepo.New("OWNER", "REPO")) output, err := runCommand(http, tc.branch, true, tc.args) if err != nil { @@ -581,13 +454,12 @@ func TestPRView_Preview(t *testing.T) { func TestPRView_web_currentBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView.json")) - cs, cmdTeardown := run.Stub() + shared.RunCommandFinder("", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/10"}, ghrepo.New("OWNER", "REPO")) + + _, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - output, err := runCommand(http, "blueberries", true, "-w") if err != nil { t.Errorf("error running command `pr view`: %v", err) @@ -601,41 +473,16 @@ func TestPRView_web_currentBranch(t *testing.T) { func TestPRView_web_noResultsForBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView_NoActiveBranch.json")) - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - - _, err := runCommand(http, "blueberries", true, "-w") - if err == nil || err.Error() != `no pull requests found for branch "blueberries"` { - t.Errorf("error running command `pr view`: %v", err) - } -} - -func TestPRView_web_numberArg(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } }`), - ) + shared.RunCommandFinder("", nil, nil) _, cmdTeardown := run.Stub() defer cmdTeardown(t) - output, err := runCommand(http, "master", true, "-w 23") - if err != nil { + _, err := runCommand(http, "blueberries", true, "-w") + if err == nil || err.Error() != `no pull requests found` { t.Errorf("error running command `pr view`: %v", err) } - - assert.Equal(t, "", output.String()) - assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", output.BrowsedURL) } func TestPRView_tty_Comments(t *testing.T) { @@ -715,10 +562,15 @@ func TestPRView_tty_Comments(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - for name, file := range tt.fixtures { - name := fmt.Sprintf(`query %s\b`, name) - http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + + if len(tt.fixtures) > 0 { + pr, err := prFromFixtures(tt.fixtures) + require.NoError(t, err) + shared.RunCommandFinder("123", pr, ghrepo.New("OWNER", "REPO")) + } else { + shared.RunCommandFinder("123", nil, nil) } + output, err := runCommand(http, tt.branch, true, tt.cli) if tt.wantsErr { assert.Error(t, err) @@ -821,10 +673,15 @@ func TestPRView_nontty_Comments(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - for name, file := range tt.fixtures { - name := fmt.Sprintf(`query %s\b`, name) - http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + + if len(tt.fixtures) > 0 { + pr, err := prFromFixtures(tt.fixtures) + require.NoError(t, err) + shared.RunCommandFinder("123", pr, ghrepo.New("OWNER", "REPO")) + } else { + shared.RunCommandFinder("123", nil, nil) } + output, err := runCommand(http, tt.branch, false, tt.cli) if tt.wantsErr { assert.Error(t, err) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index c80d47953..5c91eed9b 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -313,7 +313,7 @@ func createRun(opts *CreateOptions) error { } } - fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL) + fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.URL) return nil } diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 4cc31ff48..a964f9068 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + "reflect" + "strings" "time" "github.com/cli/cli/api" @@ -13,30 +15,106 @@ import ( "github.com/cli/cli/internal/ghrepo" ) -type Release struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - Body string `json:"body"` - IsDraft bool `json:"draft"` - IsPrerelease bool `json:"prerelease"` - CreatedAt time.Time `json:"created_at"` - PublishedAt time.Time `json:"published_at"` +var ReleaseFields = []string{ + "url", + "apiUrl", + "uploadUrl", + "tarballUrl", + "zipballUrl", + "id", + "tagName", + "name", + "body", + "isDraft", + "isPrerelease", + "createdAt", + "publishedAt", + "targetCommitish", + "author", + "assets", +} - APIURL string `json:"url"` - UploadURL string `json:"upload_url"` - HTMLURL string `json:"html_url"` - Assets []ReleaseAsset +type Release struct { + ID string `json:"node_id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + IsDraft bool `json:"draft"` + IsPrerelease bool `json:"prerelease"` + CreatedAt time.Time `json:"created_at"` + PublishedAt *time.Time `json:"published_at"` + + TargetCommitish string `json:"target_commitish"` + + APIURL string `json:"url"` + UploadURL string `json:"upload_url"` + TarballURL string `json:"tarball_url"` + ZipballURL string `json:"zipball_url"` + URL string `json:"html_url"` + Assets []ReleaseAsset Author struct { - Login string + ID string `json:"node_id"` + Login string `json:"login"` } } type ReleaseAsset struct { + ID string `json:"node_id"` Name string + Label string Size int64 State string APIURL string `json:"url"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DownloadCount int `json:"download_count"` + ContentType string `json:"content_type"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (rel *Release) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(rel).Elem() + fieldByName := func(v reflect.Value, field string) reflect.Value { + return v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(field, s) + }) + } + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "author": + data[f] = map[string]interface{}{ + "id": rel.Author.ID, + "login": rel.Author.Login, + } + case "assets": + assets := make([]interface{}, 0, len(rel.Assets)) + for _, a := range rel.Assets { + assets = append(assets, map[string]interface{}{ + "url": a.BrowserDownloadURL, + "apiUrl": a.APIURL, + "id": a.ID, + "name": a.Name, + "label": a.Label, + "size": a.Size, + "state": a.State, + "createdAt": a.CreatedAt, + "updatedAt": a.UpdatedAt, + "downloadCount": a.DownloadCount, + "contentType": a.ContentType, + }) + } + data[f] = assets + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + + return &data } // FetchRelease finds a repository release by its tagName. @@ -55,11 +133,7 @@ func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName st defer resp.Body.Close() if resp.StatusCode == 404 { - if canPush, err := api.CanPushToRepo(httpClient, baseRepo); err == nil && canPush { - return FindDraftRelease(httpClient, baseRepo, tagName) - } else if err != nil { - return nil, err - } + return FindDraftRelease(httpClient, baseRepo, tagName) } if resp.StatusCode > 299 { @@ -152,11 +226,8 @@ func FindDraftRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagNam return &r, nil } } - - if len(releases) < perPage { - break - } - page++ + //nolint:staticcheck + break } return nil, errors.New("release not found") diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 9bcccb675..a7b796bee 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -26,6 +26,7 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser + Exporter cmdutil.Exporter TagName string WebMode bool @@ -64,6 +65,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the release in the browser") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) return cmd } @@ -95,9 +97,19 @@ func viewRun(opts *ViewOptions) error { if opts.WebMode { if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(release.HTMLURL)) + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(release.URL)) } - return opts.Browser.Browse(release.HTMLURL) + return opts.Browser.Browse(release.URL) + } + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO.Out, release, opts.IO.ColorEnabled()) } if opts.IO.IsStdoutTTY() { @@ -126,10 +138,10 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { if release.IsDraft { fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.CreatedAt))))) } else { - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt))))) + fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(*release.PublishedAt))))) } - style := markdown.GetStyle(io.DetectTerminalTheme()) + style := markdown.GetStyle(io.TerminalTheme()) renderedDescription, err := markdown.Render(release.Body, style) if err != nil { return err @@ -151,7 +163,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprint(w, "\n") } - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL))) + fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.URL))) return nil } @@ -165,7 +177,7 @@ func renderReleasePlain(w io.Writer, release *shared.Release) error { if !release.IsDraft { fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) } - fmt.Fprintf(w, "url:\t%s\n", release.HTMLURL) + fmt.Fprintf(w, "url:\t%s\n", release.URL) for _, a := range release.Assets { fmt.Fprintf(w, "asset:\t%s\n", a.Name) } diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 30df5ae67..1bebcd7df 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -36,6 +36,7 @@ type ForkOptions struct { PromptClone bool PromptRemote bool RemoteName string + Organization string Rename bool } @@ -110,6 +111,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`, cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}") cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}") cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.") + cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization") return cmd } @@ -169,7 +171,7 @@ func forkRun(opts *ForkOptions) error { apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() - forkedRepo, err := api.ForkRepo(apiClient, repoToFork) + forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to fork: %w", err) diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index d1210048b..26f69aa97 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -2,12 +2,14 @@ package fork import ( "bytes" + "io/ioutil" "net/http" "net/url" "regexp" "testing" "time" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/context" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" @@ -72,8 +74,9 @@ func TestNewCmdFork(t *testing.T) { name: "blank nontty", cli: "", wants: ForkOptions{ - RemoteName: "origin", - Rename: true, + RemoteName: "origin", + Rename: true, + Organization: "", }, }, { @@ -85,6 +88,7 @@ func TestNewCmdFork(t *testing.T) { PromptClone: true, PromptRemote: true, Rename: true, + Organization: "", }, }, { @@ -104,6 +108,16 @@ func TestNewCmdFork(t *testing.T) { Rename: true, }, }, + { + name: "to org", + cli: "--org batmanshome", + wants: ForkOptions{ + RemoteName: "origin", + Remote: false, + Rename: false, + Organization: "batmanshome", + }, + }, } for _, tt := range tests { @@ -141,6 +155,7 @@ func TestNewCmdFork(t *testing.T) { assert.Equal(t, tt.wants.Remote, gotOpts.Remote) assert.Equal(t, tt.wants.PromptRemote, gotOpts.PromptRemote) assert.Equal(t, tt.wants.PromptClone, gotOpts.PromptClone) + assert.Equal(t, tt.wants.Organization, gotOpts.Organization) }) } } @@ -289,6 +304,7 @@ func TestRepoFork_in_parent_tty(t *testing.T) { assert.Equal(t, "✓ Created fork someone/REPO\n✓ Added remote origin\n", output.Stderr()) reg.Verify(t) } + func TestRepoFork_in_parent_nontty(t *testing.T) { defer stubSince(2 * time.Second)() reg := &httpmock.Registry{} @@ -409,37 +425,65 @@ func TestRepoFork_in_parent(t *testing.T) { func TestRepoFork_outside(t *testing.T) { tests := []struct { - name string - args string + name string + args string + postBody string + responseBody string + wantStderr string }{ { - name: "url arg", - args: "--clone=false http://github.com/OWNER/REPO.git", + name: "url arg", + args: "--clone=false http://github.com/OWNER/REPO.git", + postBody: "{}\n", + responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`, + wantStderr: heredoc.Doc(` + ✓ Created fork monalisa/REPO + `), }, { - name: "full name arg", - args: "--clone=false OWNER/REPO", + name: "full name arg", + args: "--clone=false OWNER/REPO", + postBody: "{}\n", + responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`, + wantStderr: heredoc.Doc(` + ✓ Created fork monalisa/REPO + `), + }, + { + name: "fork to org without clone", + args: "--clone=false OWNER/REPO --org batmanshome", + postBody: "{\"organization\":\"batmanshome\"}\n", + responseBody: `{"name":"REPO", "owner":{"login":"BatmansHome"}}`, + wantStderr: heredoc.Doc(` + ✓ Created fork BatmansHome/REPO + `), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer stubSince(2 * time.Second)() + reg := &httpmock.Registry{} - defer reg.StubWithFixturePath(200, "./forkResult.json")() + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/forks"), + func(req *http.Request) (*http.Response, error) { + bb, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, err + } + assert.Equal(t, tt.postBody, string(bb)) + return &http.Response{ + Request: req, + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewBufferString(tt.responseBody)), + }, nil + }) + httpClient := &http.Client{Transport: reg} - output, err := runCommand(httpClient, nil, true, tt.args) - if err != nil { - t.Errorf("error running command `repo fork`: %v", err) - } - + assert.NoError(t, err) assert.Equal(t, "", output.String()) - - r := regexp.MustCompile(`Created fork.*someone/REPO`) - if !r.MatchString(output.Stderr()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) - return - } + assert.Equal(t, tt.wantStderr, output.Stderr()) reg.Verify(t) }) } diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 90e31fa4a..ef574b43c 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -1,48 +1,18 @@ package list import ( - "context" + "fmt" "net/http" - "reflect" "strings" - "time" - "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/api" "github.com/cli/cli/pkg/githubsearch" "github.com/shurcooL/githubv4" - "github.com/shurcooL/graphql" ) -type Repository struct { - NameWithOwner string - Description string - IsFork bool - IsPrivate bool - IsArchived bool - PushedAt time.Time -} - -func (r Repository) Info() string { - var tags []string - - if r.IsPrivate { - tags = append(tags, "private") - } else { - tags = append(tags, "public") - } - if r.IsFork { - tags = append(tags, "fork") - } - if r.IsArchived { - tags = append(tags, "archived") - } - - return strings.Join(tags, ", ") -} - type RepositoryList struct { Owner string - Repositories []Repository + Repositories []api.Repository TotalCount int FromSearch bool } @@ -54,6 +24,7 @@ type FilterOptions struct { Language string Archived bool NonArchived bool + Fields []string } func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { @@ -67,62 +38,65 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi } variables := map[string]interface{}{ - "perPage": githubv4.Int(perPage), - "endCursor": (*githubv4.String)(nil), + "perPage": githubv4.Int(perPage), } if filter.Visibility != "" { variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility)) - } else { - variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil) } if filter.Fork { variables["fork"] = githubv4.Boolean(true) } else if filter.Source { variables["fork"] = githubv4.Boolean(false) - } else { - variables["fork"] = (*githubv4.Boolean)(nil) } + inputs := []string{"$perPage:Int!", "$endCursor:String", "$privacy:RepositoryPrivacy", "$fork:Boolean"} var ownerConnection string if owner == "" { - ownerConnection = `graphql:"repositoryOwner: viewer"` + ownerConnection = "repositoryOwner: viewer" } else { - ownerConnection = `graphql:"repositoryOwner(login: $owner)"` + ownerConnection = "repositoryOwner(login: $owner)" variables["owner"] = githubv4.String(owner) + inputs = append(inputs, "$owner:String!") } - type repositoryOwner struct { - Login string - Repositories struct { - Nodes []Repository - TotalCount int - PageInfo struct { - HasNextPage bool - EndCursor string + type result struct { + RepositoryOwner struct { + Login string + Repositories struct { + Nodes []api.Repository + TotalCount int + PageInfo struct { + HasNextPage bool + EndCursor string + } } - } `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"` + } } - query := reflect.StructOf([]reflect.StructField{ - { - Name: "RepositoryOwner", - Type: reflect.TypeOf(repositoryOwner{}), - Tag: reflect.StructTag(ownerConnection), - }, - }) - gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) + query := fmt.Sprintf(`query RepositoryList(%s) { + %s { + login + repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC }) { + nodes{%s} + totalCount + pageInfo{hasNextPage,endCursor} + } + } + }`, strings.Join(inputs, ","), ownerConnection, api.RepositoryGraphQL(filter.Fields)) + + apiClient := api.NewClientFromHTTP(client) listResult := RepositoryList{} pagination: for { - result := reflect.New(query) - err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables) + var res result + err := apiClient.GraphQL(hostname, query, variables, &res) if err != nil { return nil, err } - owner := result.Elem().FieldByName("RepositoryOwner").Interface().(repositoryOwner) + owner := res.RepositoryOwner listResult.TotalCount = owner.Repositories.TotalCount listResult.Owner = owner.Login @@ -143,47 +117,52 @@ pagination: } func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { - type query struct { + type result struct { Search struct { RepositoryCount int - Nodes []struct { - Repository Repository `graphql:"...on Repository"` - } - PageInfo struct { + Nodes []api.Repository + PageInfo struct { HasNextPage bool EndCursor string } - } `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"` + } } + query := fmt.Sprintf(`query RepositoryListSearch($query:String!,$perPage:Int!,$endCursor:String) { + search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor) { + repositoryCount + nodes{...on Repository{%s}} + pageInfo{hasNextPage,endCursor} + } + }`, api.RepositoryGraphQL(filter.Fields)) + perPage := limit if perPage > 100 { perPage = 100 } variables := map[string]interface{}{ - "query": githubv4.String(searchQuery(owner, filter)), - "perPage": githubv4.Int(perPage), - "endCursor": (*githubv4.String)(nil), + "query": githubv4.String(searchQuery(owner, filter)), + "perPage": githubv4.Int(perPage), } - gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) + apiClient := api.NewClientFromHTTP(client) listResult := RepositoryList{FromSearch: true} pagination: for { - var result query - err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables) + var result result + err := apiClient.GraphQL(hostname, query, variables, &result) if err != nil { return nil, err } listResult.TotalCount = result.Search.RepositoryCount - for _, node := range result.Search.Nodes { - if listResult.Owner == "" { - idx := strings.IndexRune(node.Repository.NameWithOwner, '/') - listResult.Owner = node.Repository.NameWithOwner[:idx] + for _, repo := range result.Search.Nodes { + if listResult.Owner == "" && repo.NameWithOwner != "" { + idx := strings.IndexRune(repo.NameWithOwner, '/') + listResult.Owner = repo.NameWithOwner[:idx] } - listResult.Repositories = append(listResult.Repositories, node.Repository) + listResult.Repositories = append(listResult.Repositories, repo) if len(listResult.Repositories) >= limit { break pagination } diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 5b0af46f0..3f1bf64f3 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -3,8 +3,10 @@ package list import ( "fmt" "net/http" + "strings" "time" + "github.com/cli/cli/api" "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -17,6 +19,7 @@ type ListOptions struct { HttpClient func() (*http.Client, error) Config func() (config.Config, error) IO *iostreams.IOStreams + Exporter cmdutil.Exporter Limit int Owner string @@ -88,10 +91,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language") cmd.Flags().BoolVar(&opts.Archived, "archived", false, "Show only archived repositories") cmd.Flags().BoolVar(&opts.NonArchived, "no-archived", false, "Omit archived repositories") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields) return cmd } +var defaultFields = []string{"nameWithOwner", "description", "isPrivate", "isFork", "isArchived", "createdAt", "pushedAt"} + func listRun(opts *ListOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -105,6 +111,10 @@ func listRun(opts *ListOptions) error { Language: opts.Language, Archived: opts.Archived, NonArchived: opts.NonArchived, + Fields: defaultFields, + } + if opts.Exporter != nil { + filter.Fields = opts.Exporter.Fields() } cfg, err := opts.Config() @@ -127,27 +137,31 @@ func listRun(opts *ListOptions) error { } defer opts.IO.StopPager() + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled()) + } + cs := opts.IO.ColorScheme() tp := utils.NewTablePrinter(opts.IO) now := opts.Now() for _, repo := range listResult.Repositories { - info := repo.Info() + info := repoInfo(repo) infoColor := cs.Gray if repo.IsPrivate { infoColor = cs.Yellow } t := repo.PushedAt - // if listResult.FromSearch { - // t = repo.UpdatedAt - // } + if repo.PushedAt == nil { + t = &repo.CreatedAt + } tp.AddField(repo.NameWithOwner, nil, cs.Bold) tp.AddField(text.ReplaceExcessiveWhitespace(repo.Description), nil, nil) tp.AddField(info, nil, infoColor) if tp.IsTTY() { - tp.AddField(utils.FuzzyAgoAbbr(now, t), nil, cs.Gray) + tp.AddField(utils.FuzzyAgoAbbr(now, *t), nil, cs.Gray) } else { tp.AddField(t.Format(time.RFC3339), nil, nil) } @@ -179,3 +193,21 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool) } return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr) } + +func repoInfo(r api.Repository) string { + var tags []string + + if r.IsPrivate { + tags = append(tags, "private") + } else { + tags = append(tags, "public") + } + if r.IsFork { + tags = append(tags, "fork") + } + if r.IsArchived { + tags = append(tags, "archived") + } + + return strings.Join(tags, ", ") +} diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index 24eaaa676..3ef1dc784 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -12,6 +12,25 @@ import ( var NotFoundError = errors.New("not found") +func fetchRepository(apiClient *api.Client, repo ghrepo.Interface, fields []string) (*api.Repository, error) { + query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) {%s} + }`, api.RepositoryGraphQL(fields)) + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + + var result struct { + Repository api.Repository + } + if err := apiClient.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { + return nil, err + } + return api.InitRepoHostname(&result.Repository, repo.RepoHost()), nil +} + type RepoReadme struct { Filename string Content string diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index fcc785e62..7e507c182 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -29,6 +29,7 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser + Exporter cmdutil.Exporter RepoArg string Web bool @@ -67,10 +68,13 @@ With '--branch', view a specific branch of the repository.`, cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a repository in the browser") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields) return cmd } +var defaultFields = []string{"name", "owner", "description"} + func viewRun(opts *ViewOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -101,11 +105,24 @@ func viewRun(opts *ViewOptions) error { } } - repo, err := api.GitHubRepo(apiClient, toView) + var readme *RepoReadme + fields := defaultFields + if opts.Exporter != nil { + fields = opts.Exporter.Fields() + } + + repo, err := fetchRepository(apiClient, toView, fields) if err != nil { return err } + if !opts.Web && opts.Exporter == nil { + readme, err = RepositoryReadme(httpClient, toView, opts.Branch) + if err != nil && !errors.Is(err, NotFoundError) { + return err + } + } + openURL := generateBranchURL(toView, opts.Branch) if opts.Web { if opts.IO.IsStdoutTTY() { @@ -114,21 +131,17 @@ func viewRun(opts *ViewOptions) error { return opts.Browser.Browse(openURL) } - fullName := ghrepo.FullName(toView) - - readme, err := RepositoryReadme(httpClient, toView, opts.Branch) - if err != nil && err != NotFoundError { - return err - } - opts.IO.DetectTerminalTheme() - - err = opts.IO.StartPager() - if err != nil { + if err := opts.IO.StartPager(); err != nil { return err } defer opts.IO.StopPager() + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled()) + } + + fullName := ghrepo.FullName(toView) stdout := opts.IO.Out if !opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go index fd91dcd70..b208b1f5a 100644 --- a/pkg/cmd/repo/view/view_test.go +++ b/pkg/cmd/repo/view/view_test.go @@ -3,10 +3,12 @@ package view import ( "bytes" "fmt" + "io" "net/http" "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmdutil" @@ -625,3 +627,51 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) { }) } } + +func Test_viewRun_json(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(false) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + + opts := &ViewOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Exporter: &testExporter{ + fields: []string{"name", "defaultBranchRef"}, + }, + } + + _, teardown := run.Stub() + defer teardown(t) + + err := viewRun(opts) + assert.NoError(t, err) + assert.Equal(t, heredoc.Doc(` + name: REPO + defaultBranchRef: main + `), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +type testExporter struct { + fields []string +} + +func (e *testExporter) Fields() []string { + return e.fields +} + +func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error { + r := data.(*api.Repository) + fmt.Fprintf(w, "name: %s\n", r.Name) + fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name) + return nil +} diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index afd926114..4fcf73250 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -134,11 +134,10 @@ func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annot err := client.REST(repo.RepoHost(), "GET", path, nil, &result) if err != nil { - var notFound *api.NotFoundError - if !errors.As(err, ¬Found) { + var httpError api.HTTPError + if errors.As(err, &httpError) && httpError.StatusCode == 404 { return []Annotation{}, nil } - return nil, err } diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index ee5195071..49693faef 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -7,7 +7,6 @@ import ( "fmt" "io/ioutil" "net/http" - "os" "testing" "github.com/cli/cli/api" @@ -149,13 +148,13 @@ func TestNewCmdRun(t *testing.T) { } func Test_magicFieldValue(t *testing.T) { - f, err := ioutil.TempFile("", "gh-test") + f, err := ioutil.TempFile(t.TempDir(), "gh-test") if err != nil { t.Fatal(err) } + defer f.Close() + fmt.Fprint(f, "file contents") - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) io, _, _, _ := iostreams.Test() diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index 7b783c3ee..0ce5a8f50 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "reflect" "sort" "strings" @@ -26,6 +27,21 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) { f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`") f.StringP("template", "t", "", "Format JSON output using a Go template") + _ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + if idx := strings.IndexRune(toComplete, ','); idx >= 0 { + toComplete = toComplete[idx+1:] + } + toComplete = strings.ToLower(toComplete) + for _, f := range fields { + if strings.HasPrefix(strings.ToLower(f), toComplete) { + results = append(results, f) + } + } + sort.Strings(results) + return results, cobra.ShellCompDirectiveNoSpace + }) + oldPreRun := cmd.PreRunE cmd.PreRunE = func(c *cobra.Command, args []string) error { if oldPreRun != nil { @@ -102,11 +118,14 @@ func (e *exportFormat) Fields() []string { return e.fields } +// Write serializes data into JSON output written to w. If the object passed as data implements exportable, +// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain +// raw data for serialization. func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error { buf := bytes.Buffer{} encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) - if err := encoder.Encode(data); err != nil { + if err := encoder.Encode(e.exportData(reflect.ValueOf(data))); err != nil { return err } @@ -121,3 +140,44 @@ func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e _, err := io.Copy(w, &buf) return err } + +func (e *exportFormat) exportData(v reflect.Value) interface{} { + switch v.Kind() { + case reflect.Ptr, reflect.Interface: + if !v.IsNil() { + return e.exportData(v.Elem()) + } + case reflect.Slice: + a := make([]interface{}, v.Len()) + for i := 0; i < v.Len(); i++ { + a[i] = e.exportData(v.Index(i)) + } + return a + case reflect.Map: + t := reflect.MapOf(v.Type().Key(), emptyInterfaceType) + m := reflect.MakeMapWithSize(t, v.Len()) + iter := v.MapRange() + for iter.Next() { + ve := reflect.ValueOf(e.exportData(iter.Value())) + m.SetMapIndex(iter.Key(), ve) + } + return m.Interface() + case reflect.Struct: + if v.CanAddr() && reflect.PtrTo(v.Type()).Implements(exportableType) { + ve := v.Addr().Interface().(exportable) + return ve.ExportData(e.fields) + } else if v.Type().Implements(exportableType) { + ve := v.Interface().(exportable) + return ve.ExportData(e.fields) + } + } + return v.Interface() +} + +type exportable interface { + ExportData([]string) *map[string]interface{} +} + +var exportableType = reflect.TypeOf((*exportable)(nil)).Elem() +var sliceOfEmptyInterface []interface{} +var emptyInterfaceType = reflect.TypeOf(sliceOfEmptyInterface).Elem() diff --git a/pkg/cmdutil/json_flags_test.go b/pkg/cmdutil/json_flags_test.go index 61780db96..84825e30a 100644 --- a/pkg/cmdutil/json_flags_test.go +++ b/pkg/cmdutil/json_flags_test.go @@ -2,6 +2,7 @@ package cmdutil import ( "bytes" + "fmt" "io/ioutil" "testing" @@ -137,6 +138,29 @@ func Test_exportFormat_Write(t *testing.T) { wantW: "{\"name\":\"hubot\"}\n", wantErr: false, }, + { + name: "call ExportData", + exporter: exportFormat{fields: []string{"field1", "field2"}}, + args: args{ + data: &exportableItem{"item1"}, + colorEnabled: false, + }, + wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n", + wantErr: false, + }, + { + name: "recursively call ExportData", + exporter: exportFormat{fields: []string{"f1", "f2"}}, + args: args{ + data: map[string]interface{}{ + "s1": []exportableItem{{"i1"}, {"i2"}}, + "s2": []exportableItem{{"i3"}}, + }, + colorEnabled: false, + }, + wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n", + wantErr: false, + }, { name: "with jq filter", exporter: exportFormat{filter: ".name"}, @@ -166,8 +190,20 @@ func Test_exportFormat_Write(t *testing.T) { return } if gotW := w.String(); gotW != tt.wantW { - t.Errorf("exportFormat.Write() = %v, want %v", gotW, tt.wantW) + t.Errorf("exportFormat.Write() = %q, want %q", gotW, tt.wantW) } }) } } + +type exportableItem struct { + Name string +} + +func (e *exportableItem) ExportData(fields []string) *map[string]interface{} { + m := map[string]interface{}{} + for _, f := range fields { + m[f] = fmt.Sprintf("%s:%s", e.Name, f) + } + return &m +} diff --git a/pkg/githubtemplate/github_template_test.go b/pkg/githubtemplate/github_template_test.go index c9f42f552..b92d4523c 100644 --- a/pkg/githubtemplate/github_template_test.go +++ b/pkg/githubtemplate/github_template_test.go @@ -261,12 +261,11 @@ func TestFindLegacy(t *testing.T) { } func TestExtractName(t *testing.T) { - tmpfile, err := ioutil.TempFile("", "gh-cli") + tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli") if err != nil { t.Fatal(err) } - tmpfile.Close() - defer os.Remove(tmpfile.Name()) + defer tmpfile.Close() type args struct { filePath string @@ -322,12 +321,11 @@ about: This is how you report bugs } func TestExtractContents(t *testing.T) { - tmpfile, err := ioutil.TempFile("", "gh-cli") + tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli") if err != nil { t.Fatal(err) } - tmpfile.Close() - defer os.Remove(tmpfile.Name()) + defer tmpfile.Close() type args struct { filePath string diff --git a/pkg/jsoncolor/jsoncolor.go b/pkg/jsoncolor/jsoncolor.go index cf3bd064c..d7c808a14 100644 --- a/pkg/jsoncolor/jsoncolor.go +++ b/pkg/jsoncolor/jsoncolor.go @@ -10,7 +10,7 @@ import ( const ( colorDelim = "1;38" // bright white colorKey = "1;34" // bright blue - colorNull = "1;30" // gray + colorNull = "36" // cyan colorString = "32" // green colorBool = "33" // yellow ) diff --git a/script/distributions b/script/distributions index 20308bbf6..51b4d194d 100644 --- a/script/distributions +++ b/script/distributions @@ -142,3 +142,29 @@ Components: main Description: The GitHub CLI - ubuntu cosmic repo SignWith: C99B11DEB97541F0 DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: hirsute +Architectures: i386 amd64 armhf arm64 +Components: main +Description: The GitHub CLI - ubuntu hirsute repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: kali-rolling +Architectures: i386 amd64 armhf arm64 +Components: main +Description: The GitHub CLI - kali repo +SignWith: C99B11DEB97541F0 + +Origin: gh +Label: gh +Codename: impish +Architectures: i386 amd64 armhf arm64 +Components: main +Description: The GitHub CLI - ubuntu impish repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu