From 9bdc63c4ca6de084d5a9db3b068aa6314e1e81e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 28 Apr 2021 19:25:27 +0200 Subject: [PATCH 01/27] Eliminate API overfetching in `pr` commands This completely rewrites the PR lookup mechanism so that the caller must specify the GraphQL fields to query for each PR. Additionally, this fixes some export problems with `pr view --json`. Features: - Each pr command now gets assigned a concept of a Finder. This makes it easier to stub the PR in tests without having to stub the underlying HTTP calls or git invocations. - `pr view --web` is much faster since it only fetches the "url" field. - `pr diff 123` now skips a whole API call where a whole PR was unnecessarily preloaded just to access its diff in a subsequent call. - PullRequestGraphQL query builder is now used to construct queries. - A bunch of individual commands are now freed of having to know about concepts such as BaseRepo, Branch, Config, or Remotes. --- api/queries_comments.go | 18 - api/queries_issue.go | 4 + api/queries_pr.go | 339 ++----------- api/queries_pr_test.go | 29 -- api/reaction_groups.go | 9 - pkg/cmd/pr/checkout/checkout.go | 32 +- pkg/cmd/pr/checks/checks.go | 34 +- pkg/cmd/pr/checks/checks_test.go | 23 +- pkg/cmd/pr/close/close.go | 25 +- pkg/cmd/pr/comment/comment.go | 32 +- pkg/cmd/pr/comment/comment_test.go | 33 +- pkg/cmd/pr/create/create.go | 13 +- pkg/cmd/pr/diff/diff.go | 26 +- pkg/cmd/pr/edit/edit.go | 25 +- pkg/cmd/pr/edit/edit_test.go | 2 - pkg/cmd/pr/merge/merge.go | 30 +- pkg/cmd/pr/merge/merge_test.go | 479 ++++++++---------- pkg/cmd/pr/ready/ready.go | 20 +- pkg/cmd/pr/reopen/reopen.go | 25 +- pkg/cmd/pr/review/review.go | 27 +- pkg/cmd/pr/shared/finder.go | 328 ++++++++++++ .../shared/{lookup_test.go => finder_test.go} | 30 +- pkg/cmd/pr/shared/lookup.go | 121 ----- pkg/cmd/pr/view/view.go | 96 +--- pkg/cmd/run/shared/shared.go | 5 +- 25 files changed, 769 insertions(+), 1036 deletions(-) create mode 100644 pkg/cmd/pr/shared/finder.go rename pkg/cmd/pr/shared/{lookup_test.go => finder_test.go} (78%) delete mode 100644 pkg/cmd/pr/shared/lookup.go diff --git a/api/queries_comments.go b/api/queries_comments.go index db6ad25e7..f02322c22 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -135,24 +135,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 7804a7613..35e4e418f 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -98,6 +98,10 @@ type IssuesDisabledError struct { error } +type Owner struct { + Login string `json:"login"` +} + type Author struct { Login string `json:"login"` } diff --git a/api/queries_pr.go b/api/queries_pr.go index e3a575562..38f9f5ee4 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,10 +57,8 @@ type PullRequest struct { Author Author MergedBy *Author - HeadRepositoryOwner struct { - Login string `json:"login"` - } - HeadRepository struct { + HeadRepositoryOwner Owner + HeadRepository struct { Name string } IsCrossRepository bool @@ -77,27 +75,7 @@ type PullRequest struct { Commits struct { TotalCount int - Nodes []struct { - Commit struct { - Oid string - StatusCheckRollup struct { - Contexts struct { - Nodes []struct { - TypeName string `json:"__typename"` - Name string `json:"name"` - Context string `json:"context,omitempty"` - State string `json:"state,omitempty"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - StartedAt time.Time `json:"startedAt"` - CompletedAt time.Time `json:"completedAt"` - DetailsURL string `json:"detailsUrl"` - TargetURL string `json:"targetUrl,omitempty"` - } - } - } - } - } + Nodes []PullRequestCommit } Assignees Assignees Labels Labels @@ -113,6 +91,37 @@ type Commit struct { OID string `json:"oid"` } +type PullRequestCommit struct { + Commit PullRequestCommitCommit +} + +// PullRequestCommitCommit is like "Commit" but with StatusCheckRollup +type PullRequestCommitCommit struct { + Oid string + StatusCheckRollup struct { + Contexts struct { + Nodes []struct { + TypeName string `json:"__typename"` + Name string `json:"name"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + DetailsURL string `json:"detailsUrl"` + TargetURL string `json:"targetUrl,omitempty"` + } + } + } +} + +func (pr *PullRequest) StubCommit(oid string) { + pr.Commits.Nodes = append(pr.Commits.Nodes, PullRequestCommit{ + Commit: PullRequestCommitCommit{Oid: oid}, + }) +} + type PullRequestFile struct { Path string `json:"path"` Additions int `json:"additions"` @@ -138,14 +147,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) @@ -247,7 +248,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 +561,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_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/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/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index f7f73bb28..03d04a1a9 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,10 @@ 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, } - - 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 +82,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 +108,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/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 1a11f3d44..b9091379f 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,13 +70,10 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co } func checksRun(opts *ChecksOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err + findOptions := shared.FindOptions{ + Selector: opts.SelectorArg, } - 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 } diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index fdcce3f9e..ca743d856 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -2,10 +2,8 @@ package checks import ( "bytes" - "net/http" "testing" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" @@ -174,10 +172,7 @@ func Test_checksRun(t *testing.T) { io.SetStdoutTTY(!tt.nontty) opts := &ChecksOptions{ - IO: io, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, + IO: io, SelectorArg: "123", } @@ -190,10 +185,6 @@ func Test_checksRun(t *testing.T) { reg.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture)) } - opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - err := checksRun(opts) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) @@ -246,15 +237,9 @@ 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", }) assert.NoError(t, err) diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 850bd7631..a1e6e3dff 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,13 +58,10 @@ 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, } - 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 } @@ -81,6 +74,12 @@ func closeRun(opts *CloseOptions) error { 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) 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..c368e4e43 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) } 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/edit/edit.go b/pkg/cmd/pr/edit/edit.go index dbf0321f9..888dc6ec2 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,10 @@ 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, } - 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 } @@ -184,6 +175,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..c918f4a4c 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -450,11 +450,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) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 31a91d6be..298d59b7f 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 { diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 16ad0ed6d..df562e194 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,14 @@ 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 runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -208,24 +214,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 +247,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 +285,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 +320,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 +358,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 +385,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 +404,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 +446,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", + } + pr.StubCommit("COMMITSHA1") + + shared.RunCommandFinder( + "", + pr, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -504,7 +476,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 +490,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", + } + pr.StubCommit("COMMITSHA1") + + shared.RunCommandFinder( + "", + pr, + baseRepo("OWNER", "REPO", "master"), + ) + http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { @@ -549,7 +517,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 +532,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 +552,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 +568,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 +606,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 +643,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) @@ -718,12 +679,17 @@ func TestPrMerge_alreadyMerged(t *testing.T) { 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 +706,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 +734,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 +756,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 +790,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 +819,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 +860,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 +891,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 +930,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 +948,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 +967,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 +983,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..a00563b4b 100644 --- a/pkg/cmd/pr/ready/ready.go +++ b/pkg/cmd/pr/ready/ready.go @@ -24,6 +24,8 @@ type ReadyOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) + Finder shared.PRFinder + SelectorArg string } @@ -47,8 +49,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,13 +72,10 @@ 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, } - 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,6 +88,12 @@ func readyRun(opts *ReadyOptions) error { 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/reopen/reopen.go b/pkg/cmd/pr/reopen/reopen.go index f22b8bfd2..72fb13659 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,13 +50,10 @@ 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, } - 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 } @@ -75,6 +68,12 @@ func reopenRun(opts *ReopenOptions) error { 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/review/review.go b/pkg/cmd/pr/review/review.go index 1ff213bcb..606af85dc 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,10 @@ 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, } - 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 +174,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/shared/finder.go b/pkg/cmd/pr/shared/finder.go new file mode 100644 index 000000000..cd7e3c314 --- /dev/null +++ b/pkg/cmd/pr/shared/finder.go @@ -0,0 +1,328 @@ +package shared + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/set" +) + +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() (context.Remotes, error) + httpClient func() (*http.Client, error) + 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, + } +} + +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) { + runCommandFinder = NewMockFinder(selector, pr, repo) +} + +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") + } + + _ = f.parseURL(opts.Selector) + + 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 err := f.parseCurrentBranch(); err != nil { + return nil, nil, err + } + } 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() + } + + if f.prNumber > 0 { + if len(opts.Fields) == 1 && opts.Fields[0] == "number" { + // 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, opts.Fields) + return pr, f.repo, err + } + + pr, err := findForBranch(httpClient, f.repo, opts.BaseBranch, f.branchName, opts.States, opts.Fields) + + // TODO: preload view: api.ReviewsForPullRequest, api.CommentsForPullRequest + // TODO: preload checks: get all checks + return pr, f.repo, err +} + +var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) + +func (f *finder) parseURL(prURL string) error { + if prURL == "" { + return fmt.Errorf("invalid URL: %q", prURL) + } + + u, err := url.Parse(prURL) + if err != nil { + return err + } + + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("invalid scheme: %s", u.Scheme) + } + + m := pullURLRE.FindStringSubmatch(u.Path) + if m == nil { + return fmt.Errorf("not a pull request URL: %s", prURL) + } + + f.repo = ghrepo.NewWithHost(m[1], m[2], u.Hostname()) + f.prNumber, _ = strconv.Atoi(m[3]) + return nil +} + +var prHeadRE = regexp.MustCompile(`^refs/pull/(\d+)/head$`) + +func (f *finder) parseCurrentBranch() error { + prHeadRef, err := f.branchFn() + if err != nil { + return err + } + + branchConfig := git.ReadBranchConfig(prHeadRef) + + // the branch is configured to merge a special PR head ref + if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { + f.prNumber, _ = strconv.Atoi(m[1]) + return 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) + } + } + + f.branchName = prHeadRef + return 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)} +} + +type NotFoundError struct { + error +} + +func (err *NotFoundError) Unwrap() error { + return err.error +} + +func NewMockFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) PRFinder { + return &mockFinder{ + expectSelector: selector, + pr: pr, + repo: repo, + } +} + +type mockFinder struct { + called bool + expectSelector 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 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 +} diff --git a/pkg/cmd/pr/shared/lookup_test.go b/pkg/cmd/pr/shared/finder_test.go similarity index 78% rename from pkg/cmd/pr/shared/lookup_test.go rename to pkg/cmd/pr/shared/finder_test.go index 4d843d7ae..f3600962e 100644 --- a/pkg/cmd/pr/shared/lookup_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -4,13 +4,12 @@ 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) { +func TestFind(t *testing.T) { type args struct { baseRepoFn func() (ghrepo.Interface, error) branchFn func() (string, error) @@ -68,12 +67,6 @@ func TestPRFromArgs(t *testing.T) { 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":{ @@ -87,17 +80,30 @@ func TestPRFromArgs(t *testing.T) { 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) } - httpClient := &http.Client{Transport: reg} - pr, repo, err := PRFromArgs(api.NewClientFromHTTP(httpClient), tt.args.baseRepoFn, tt.args.branchFn, tt.args.remotesFn, tt.args.selector) + + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + baseRepoFn: tt.args.baseRepoFn, + branchFn: tt.args.branchFn, + remotesFn: tt.args.remotesFn, + } + + pr, repo, err := f.Find(FindOptions{ + Selector: tt.args.selector, + }) if (err != nil) != tt.wantErr { - t.Errorf("IssueFromArg() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Find() error = %v, wantErr %v", err, tt.wantErr) return } + if pr.Number != tt.wantPR { - t.Errorf("want issue #%d, got #%d", tt.wantPR, pr.Number) + t.Errorf("want pr #%d, got #%d", tt.wantPR, pr.Number) } repoURL := ghrepo.GenerateRepoURL(repo, "") if repoURL != tt.wantRepo { 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/view/view.go b/pkg/cmd/pr/view/view.go index 4e6300297..b8e32189d 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,26 @@ 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", + "baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository", + "reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone", + "comments", // TODO: fetch only 1 last comment unless `opts.Comments` was set + "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 } @@ -413,51 +415,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/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 } From 2f94adabb2ddbda4cfbb717019714dca6f0a3fa1 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Fri, 7 May 2021 10:21:26 +0000 Subject: [PATCH 02/27] Use `T.TempDir` for temporary dirs in tests (#3580) --- internal/docs/man_test.go | 3 +-- internal/docs/markdown_test.go | 3 +-- pkg/cmd/api/api_test.go | 20 +++++++++++--------- pkg/cmd/issue/create/create_test.go | 4 ++-- pkg/cmd/pr/create/create_test.go | 4 ++-- pkg/cmd/pr/shared/preserve_test.go | 17 ++++------------- pkg/cmd/workflow/run/run_test.go | 7 +++---- pkg/githubtemplate/github_template_test.go | 10 ++++------ 8 files changed, 28 insertions(+), 40 deletions(-) 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/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/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/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 8480462ed..0633e3dad 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" @@ -309,8 +308,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", 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/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/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 From cc94dc762d14eeb54e13cb184a9c55c2234a3b20 Mon Sep 17 00:00:00 2001 From: Gowtham Munukutla Date: Tue, 4 May 2021 18:50:27 +0530 Subject: [PATCH 03/27] shift gist validation to server rather than client --- pkg/cmd/gist/create/create.go | 15 ++++++ pkg/cmd/gist/create/create_test.go | 80 +++++++++++++++++++++++++----- pkg/cmd/gist/empty.txt | 0 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 pkg/cmd/gist/empty.txt diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 645638bca..291162ad4 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -153,6 +153,12 @@ func createRun(opts *CreateOptions) error { 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.") } + if httpError.StatusCode == http.StatusUnprocessableEntity { + if detectEmptyFiles(files) { + fmt.Fprintf(errOut, "%s Failed to create gist: %s", 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..d4069cfdf 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -20,6 +20,7 @@ import ( const ( fixtureFile = "../fixture.txt" + emptyFile = "../empty.txt" ) func Test_processFiles(t *testing.T) { @@ -165,14 +166,15 @@ func TestNewCmdCreate(t *testing.T) { func Test_createRun(t *testing.T) { 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 +195,7 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, { name: "with description", @@ -213,6 +216,7 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, { name: "multiple files", @@ -236,6 +240,25 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, + }, + { + name: "file with empty content", + opts: &CreateOptions{ + Filenames: []string{emptyFile}, + }, + wantOut: "", + wantStderr: "- Creating gist empty.txt\nX 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{}{}, + }, + }, + responseStatus: http.StatusUnprocessableEntity, }, { name: "stdin arg", @@ -256,6 +279,7 @@ func Test_createRun(t *testing.T) { }, }, }, + responseStatus: http.StatusOK, }, { name: "web arg", @@ -277,14 +301,20 @@ 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.StatusUnprocessableEntity { + reg.Register(httpmock.REST("POST", "gists"), + httpmock.StatusStringResponse(http.StatusUnprocessableEntity, "")) + } else { + reg.Register(httpmock.REST("POST", "gists"), + httpmock.JSONResponse(struct { + Html_url string + }{"https://gist.github.com/aa5a315d61ae9438b18d"})) + } mockClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil @@ -325,6 +355,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) { diff --git a/pkg/cmd/gist/empty.txt b/pkg/cmd/gist/empty.txt new file mode 100644 index 000000000..e69de29bb From 70a9621928bfc9c66ffc057938ab27a928cc0820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 7 May 2021 13:50:33 +0200 Subject: [PATCH 04/27] :nail_care: cleanup in gist create --- pkg/cmd/gist/create/create.go | 4 +- pkg/cmd/gist/create/create_test.go | 61 +++++++++++++++--------------- pkg/cmd/gist/empty.txt | 0 pkg/cmd/gist/fixture.txt | 1 - pkg/cmd/gist/shared/shared.go | 2 +- 5 files changed, 33 insertions(+), 35 deletions(-) delete mode 100644 pkg/cmd/gist/empty.txt delete mode 100644 pkg/cmd/gist/fixture.txt diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 291162ad4..c10b5d27b 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -151,11 +151,11 @@ 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", cs.FailureIcon(), "a gist file cannot be blank") + fmt.Fprintf(errOut, "%s Failed to create gist: %s\n", cs.FailureIcon(), "a gist file cannot be blank") return cmdutil.SilentError } } diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index d4069cfdf..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,11 +20,6 @@ import ( "github.com/stretchr/testify/assert" ) -const ( - fixtureFile = "../fixture.txt" - emptyFile = "../empty.txt" -) - func Test_processFiles(t *testing.T) { fakeStdin := strings.NewReader("hey cool how is it going") files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"}) @@ -165,6 +162,12 @@ 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 @@ -247,15 +250,18 @@ func Test_createRun(t *testing.T) { opts: &CreateOptions{ Filenames: []string{emptyFile}, }, - wantOut: "", - wantStderr: "- Creating gist empty.txt\nX Failed to create gist: a gist file cannot be blank", - wantErr: true, + 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{}{}, + "empty.txt": map[string]interface{}{"content": " \t\n"}, }, }, responseStatus: http.StatusUnprocessableEntity, @@ -306,14 +312,16 @@ func Test_createRun(t *testing.T) { } for _, tt := range tests { reg := &httpmock.Registry{} - if tt.responseStatus == http.StatusUnprocessableEntity { - reg.Register(httpmock.REST("POST", "gists"), - httpmock.StatusStringResponse(http.StatusUnprocessableEntity, "")) + 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.JSONResponse(struct { - Html_url string - }{"https://gist.github.com/aa5a315d61ae9438b18d"})) + reg.Register( + httpmock.REST("POST", "gists"), + httpmock.StatusStringResponse(tt.responseStatus, "{}")) } mockClient := func() (*http.Client, error) { @@ -388,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/empty.txt b/pkg/cmd/gist/empty.txt deleted file mode 100644 index e69de29bb..000000000 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 { From c50d390cf52c7726368f9928cadf9f0266a32814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 7 May 2021 22:09:58 +0200 Subject: [PATCH 05/27] Fix tests --- api/queries_pr.go | 8 +- pkg/cmd/pr/checkout/checkout.go | 1 + pkg/cmd/pr/checkout/checkout_test.go | 367 ++++-------------- pkg/cmd/pr/checks/checks.go | 22 +- pkg/cmd/pr/checks/checks_test.go | 83 ++-- pkg/cmd/pr/checks/fixtures/allPassing.json | 7 +- pkg/cmd/pr/checks/fixtures/someFailing.json | 7 +- pkg/cmd/pr/checks/fixtures/somePending.json | 7 +- pkg/cmd/pr/checks/fixtures/withStatuses.json | 7 +- pkg/cmd/pr/close/close.go | 28 +- pkg/cmd/pr/close/close_test.go | 195 +++++++--- pkg/cmd/pr/create/create_test.go | 74 +--- pkg/cmd/pr/diff/diff_test.go | 92 +---- pkg/cmd/pr/edit/edit.go | 1 + pkg/cmd/pr/edit/edit_test.go | 46 +-- pkg/cmd/pr/merge/merge_test.go | 9 +- pkg/cmd/pr/ready/ready.go | 15 +- pkg/cmd/pr/ready/ready_test.go | 105 ++--- pkg/cmd/pr/reopen/reopen.go | 5 +- pkg/cmd/pr/reopen/reopen_test.go | 125 ++---- pkg/cmd/pr/review/review.go | 1 + pkg/cmd/pr/review/review_test.go | 310 +++------------ pkg/cmd/pr/shared/finder.go | 5 + pkg/cmd/pr/shared/finder_test.go | 18 + pkg/cmd/pr/view/fixtures/prView.json | 54 --- .../prViewPreviewDraftStatebyBranch.json | 54 --- .../view/fixtures/prViewPreviewNoReviews.json | 1 - .../prViewPreviewWithMetadataByBranch.json | 139 ------- .../pr/view/fixtures/prView_EmptyBody.json | 52 --- .../view/fixtures/prView_NoActiveBranch.json | 15 - pkg/cmd/pr/view/view_test.go | 319 +++++---------- 31 files changed, 573 insertions(+), 1599 deletions(-) delete mode 100644 pkg/cmd/pr/view/fixtures/prView.json delete mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json delete mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json delete mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json delete mode 100644 pkg/cmd/pr/view/fixtures/prView_EmptyBody.json delete mode 100644 pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json diff --git a/api/queries_pr.go b/api/queries_pr.go index 38f9f5ee4..b5441a359 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -58,9 +58,7 @@ type PullRequest struct { Author Author MergedBy *Author HeadRepositoryOwner Owner - HeadRepository struct { - Name string - } + HeadRepository PRRepository IsCrossRepository bool IsDraft bool MaintainerCanModify bool @@ -87,6 +85,10 @@ type PullRequest struct { ReviewRequests ReviewRequests } +type PRRepository struct { + Name string +} + type Commit struct { OID string `json:"oid"` } diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 03d04a1a9..84b16709f 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -72,6 +72,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr func checkoutRun(opts *CheckoutOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, + Fields: []string{"number", "headRefName", "headRepository", "headRepositoryOwner"}, } pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index ed1775f44..583602628 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,8 @@ 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") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -103,141 +122,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 +145,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 +163,8 @@ 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") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -294,26 +178,15 @@ 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") + shared.RunCommandFinder("123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -327,26 +200,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 +220,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 +240,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 +260,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 +276,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 +299,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 +321,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 +342,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 +359,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 b9091379f..e97d1e46f 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -72,21 +72,16 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co func checksRun(opts *ChecksOptions) error { 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 } - 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() if opts.WebMode { @@ -97,6 +92,15 @@ func checksRun(opts *ChecksOptions) error { return opts.Browser.Browse(openURL) } + 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) + } + passing := 0 failing := 0 pending := 0 diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index ca743d856..6cedd6d32 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -2,14 +2,20 @@ package checks import ( "bytes" + "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) { @@ -64,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, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, wantOut: "", wantErr: "no checks reported on the 'master' branch", }, @@ -122,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, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, wantOut: "", wantErr: "no checks reported on the 'master' branch", }, @@ -168,21 +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, + 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)) + Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")), } err := checksRun(opts) @@ -223,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) @@ -241,11 +224,11 @@ func TestChecksRun_web(t *testing.T) { 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..5116b5916 100644 --- a/pkg/cmd/pr/checks/fixtures/allPassing.json +++ b/pkg/cmd/pr/checks/fixtures/allPassing.json @@ -1,4 +1,4 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, "commits": { "nodes": [ @@ -37,5 +37,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/checks/fixtures/someFailing.json b/pkg/cmd/pr/checks/fixtures/someFailing.json index 0e53cdb79..887e22ab3 100644 --- a/pkg/cmd/pr/checks/fixtures/someFailing.json +++ b/pkg/cmd/pr/checks/fixtures/someFailing.json @@ -1,4 +1,4 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, "commits": { "nodes": [ @@ -37,5 +37,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/checks/fixtures/somePending.json b/pkg/cmd/pr/checks/fixtures/somePending.json index 6e36a5cd3..5214a7930 100644 --- a/pkg/cmd/pr/checks/fixtures/somePending.json +++ b/pkg/cmd/pr/checks/fixtures/somePending.json @@ -1,4 +1,4 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, "commits": { "nodes": [ @@ -37,5 +37,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/checks/fixtures/withStatuses.json b/pkg/cmd/pr/checks/fixtures/withStatuses.json index 0ce8b9c66..2b4a808c7 100644 --- a/pkg/cmd/pr/checks/fixtures/withStatuses.json +++ b/pkg/cmd/pr/checks/fixtures/withStatuses.json @@ -1,4 +1,4 @@ -{ "data": { "repository": { "pullRequest": { +{ "number": 123, "commits": { "nodes": [ @@ -34,5 +34,6 @@ } } } - ]} -} } } } + ] + } +} diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index a1e6e3dff..041d48edc 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -60,6 +60,7 @@ func closeRun(opts *CloseOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, + Fields: []string{"state", "number", "title", "isCrossRepository", "headRefName"}, } pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { @@ -67,10 +68,10 @@ func closeRun(opts *CloseOptions) error { } 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 } @@ -87,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 @@ -112,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) } } @@ -124,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 Avoiding 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..4024398dd 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) + ! Avoiding 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/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 8480462ed..8cc2549eb 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -17,6 +17,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 +248,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(` @@ -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_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 888dc6ec2..ea1b73562 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -151,6 +151,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, + Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "milestone"}, } pr, repo, err := opts.Finder.Find(findOptions) if err != nil { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index c918f4a4c..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) }, @@ -463,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`), @@ -549,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/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index df562e194..a91b9740d 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -668,12 +668,9 @@ 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) { diff --git a/pkg/cmd/pr/ready/ready.go b/pkg/cmd/pr/ready/ready.go index a00563b4b..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,7 @@ 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 @@ -33,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{ @@ -74,6 +64,7 @@ func readyRun(opts *ReadyOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, + Fields: []string{"id", "number", "state", "isDraft"}, } pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { @@ -81,10 +72,10 @@ func readyRun(opts *ReadyOptions) error { } 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 } 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 72fb13659..2750d1cec 100644 --- a/pkg/cmd/pr/reopen/reopen.go +++ b/pkg/cmd/pr/reopen/reopen.go @@ -52,6 +52,7 @@ func reopenRun(opts *ReopenOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, + Fields: []string{"id", "number", "state", "title"}, } pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { @@ -59,12 +60,12 @@ func reopenRun(opts *ReopenOptions) error { } 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 } 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 606af85dc..06452149f 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -147,6 +147,7 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co func reviewRun(opts *ReviewOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, + Fields: []string{"id", "number"}, } pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { 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 index cd7e3c314..c17885ab2 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -292,10 +292,15 @@ func (err *NotFoundError) Unwrap() error { } func NewMockFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) PRFinder { + var err error + if pr == nil { + err = &NotFoundError{errors.New("no pull requests found")} + } return &mockFinder{ expectSelector: selector, pr: pr, repo: repo, + err: err, } } diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index f3600962e..7149bd4f8 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -15,6 +15,7 @@ func TestFind(t *testing.T) { branchFn func() (string, error) remotesFn func() (context.Remotes, error) selector string + fields []string } tests := []struct { name string @@ -28,6 +29,7 @@ func TestFind(t *testing.T) { name: "number argument", args: args{ selector: "13", + fields: []string{"id", "number"}, baseRepoFn: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") }, @@ -42,10 +44,24 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/OWNER/REPO", }, + { + 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") }, @@ -64,6 +80,7 @@ func TestFind(t *testing.T) { 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) { @@ -96,6 +113,7 @@ func TestFind(t *testing.T) { pr, repo, err := f.Find(FindOptions{ Selector: tt.args.selector, + Fields: tt.args.fields, }) if (err != nil) != tt.wantErr { t.Errorf("Find() error = %v, wantErr %v", err, tt.wantErr) 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_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) From 3cbd5b49346378136f5e85601c42882cf0ef4d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 10 May 2021 17:09:03 +0200 Subject: [PATCH 06/27] Add `repo fork --org` functionality (#3611) Co-authored-by: Gowtham Munukutla --- api/queries_repo.go | 15 +++++- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/repo/fork/fork.go | 4 +- pkg/cmd/repo/fork/fork_test.go | 84 ++++++++++++++++++++++++++-------- 4 files changed, 81 insertions(+), 24 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 90d3949a5..2918ee1d6 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -312,9 +312,20 @@ type repositoryV3 struct { } // 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 { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index fc2e70e26..5093d53d1 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -674,7 +674,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/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) }) } From 26d2e5c5cef62b3667eb2f05051a7b17a9fbe9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 11 May 2021 17:08:28 +0200 Subject: [PATCH 07/27] Rework our pull request template (#3584) --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ .github/PULL_REQUEST_TEMPLATE/bug_fix.md | 19 ------------------- 2 files changed, 4 insertions(+), 19 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE/bug_fix.md 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 - -- From 02b7a7178336628311b75fb59e60f33a6594ab65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 11 May 2021 21:21:57 +0200 Subject: [PATCH 08/27] Add project layout documentation (#3587) --- .github/CONTRIBUTING.md | 2 + docs/project-layout.md | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 docs/project-layout.md 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/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. From fddc888a69a8ab6135dc0ec0516df670bed363b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 12 May 2021 16:56:52 +0200 Subject: [PATCH 09/27] Fix "null" display in colored JSON output "null" was previously rendered in "bright black", an ANSI color that is not guaranteed to be visible at all depending on the terminal. Switch the color to cyan to ensure that "null" is visible. --- pkg/jsoncolor/jsoncolor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ) From 5f0301c990bcac47cfdb36273032050da40fcaca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 11 May 2021 18:25:20 +0200 Subject: [PATCH 10/27] Have Exporter.Write automatically call ExportData on given data structure --- api/export_pr.go | 20 ++------------- api/export_pr_test.go | 25 ------------------ pkg/cmd/issue/list/list.go | 3 +-- pkg/cmd/issue/status/status.go | 8 +++--- pkg/cmd/issue/view/view.go | 3 +-- pkg/cmd/pr/list/list.go | 3 +-- pkg/cmd/pr/status/status.go | 8 +++--- pkg/cmd/pr/view/view.go | 3 +-- pkg/cmdutil/json_flags.go | 47 +++++++++++++++++++++++++++++++++- pkg/cmdutil/json_flags_test.go | 38 ++++++++++++++++++++++++++- 10 files changed, 97 insertions(+), 61 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index dc70ee4fa..8939d9e80 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -6,6 +6,7 @@ import ( ) func (issue *Issue) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(issue).Elem() data := map[string]interface{}{} for _, f := range fields { @@ -25,7 +26,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,6 +35,7 @@ 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 { @@ -75,7 +76,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 +84,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..4e02f9f09 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -90,31 +90,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 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/view.go b/pkg/cmd/issue/view/view.go index e90c6a528..6888cc791 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -116,8 +116,7 @@ func viewRun(opts *ViewOptions) error { 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() { 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/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/view.go b/pkg/cmd/pr/view/view.go index 4e6300297..184892ec5 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -117,8 +117,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 { diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index 7b783c3ee..914c5024f 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "reflect" "sort" "strings" @@ -102,11 +103,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 +125,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 +} From 02a2ed2f73789e56963e46a52b07a3ec8a840c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 11 May 2021 20:00:36 +0200 Subject: [PATCH 11/27] Add `repo view --json` export functionality --- api/export_pr.go | 4 +- api/export_repo.go | 53 +++++++++ api/queries_issue.go | 5 +- api/queries_repo.go | 189 ++++++++++++++++++++++++++++----- api/queries_repo_test.go | 6 +- api/query_builder.go | 130 +++++++++++++++++++++++ context/context.go | 2 +- pkg/cmd/repo/view/http.go | 19 ++++ pkg/cmd/repo/view/view.go | 35 ++++-- pkg/cmd/repo/view/view_test.go | 50 +++++++++ 10 files changed, 447 insertions(+), 46 deletions(-) create mode 100644 api/export_repo.go diff --git a/api/export_pr.go b/api/export_pr.go index 8939d9e80..9a0e10702 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -13,7 +13,7 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} { switch f { case "milestone": if issue.Milestone.Title != "" { - data[f] = &issue.Milestone + data[f] = map[string]string{"title": issue.Milestone.Title} } else { data[f] = nil } @@ -44,7 +44,7 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { data[f] = map[string]string{"name": pr.HeadRepository.Name} case "milestone": if pr.Milestone.Title != "" { - data[f] = &pr.Milestone + data[f] = map[string]string{"title": pr.Milestone.Title} } else { data[f] = nil } 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/queries_issue.go b/api/queries_issue.go index 2dfab5742..738f36ff7 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -91,7 +91,10 @@ 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 { diff --git a/api/queries_repo.go b/api/queries_repo.go index 90d3949a5..c4f285c24 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,7 +439,6 @@ type repositoryV3 struct { NodeID string Name string CreatedAt time.Time `json:"created_at"` - CloneURL string `json:"clone_url"` Owner struct { Login string } @@ -324,7 +457,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 +839,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..25862e179 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -186,3 +186,133 @@ func PullRequestGraphQL(fields []string) string { } 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) + } + } + return strings.Join(q, ",") +} 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/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 +} From df2ae17b548a6c1e38190bafb3edbbe9d02475dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 12 May 2021 17:35:02 +0200 Subject: [PATCH 12/27] Bump Cobra to v1.1.3 --- go.mod | 2 +- go.sum | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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= From b09c1f7a6f54651353e95a5fc59af9f1aab671d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 12 May 2021 17:35:17 +0200 Subject: [PATCH 13/27] Add shell completion for the `--json` flag --- pkg/cmdutil/json_flags.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index 7b783c3ee..e0abec093 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -26,6 +26,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 { From adbfb6e8deb49667376f53ec60b9bd21dde0658a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 May 2021 15:37:39 +0200 Subject: [PATCH 14/27] Merge pull request #3638 from cli/release-discussion Create a Release Discussion on every new release --- .github/workflows/releases.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index ccb42c8df..4f593f36a 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -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 From a2307e357dfa74423c8641794e728b1017bc03b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 May 2021 16:32:01 +0200 Subject: [PATCH 15/27] Add `repo list --json` support --- pkg/cmd/repo/list/http.go | 133 ++++++++++++++++---------------------- pkg/cmd/repo/list/list.go | 42 ++++++++++-- 2 files changed, 93 insertions(+), 82 deletions(-) diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 90e31fa4a..1dbe372e4 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 { + for _, repo := range result.Search.Nodes { if listResult.Owner == "" { - idx := strings.IndexRune(node.Repository.NameWithOwner, '/') - listResult.Owner = node.Repository.NameWithOwner[:idx] + 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, ", ") +} From 3f3d4e38d44e21727e55fc628113357f9918c268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 May 2021 16:43:39 +0200 Subject: [PATCH 16/27] Avoid crash when `--json` doesn't request `nameWithOwner` --- pkg/cmd/repo/list/http.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 1dbe372e4..ef574b43c 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -158,7 +158,7 @@ pagination: listResult.TotalCount = result.Search.RepositoryCount for _, repo := range result.Search.Nodes { - if listResult.Owner == "" { + if listResult.Owner == "" && repo.NameWithOwner != "" { idx := strings.IndexRune(repo.NameWithOwner, '/') listResult.Owner = repo.NameWithOwner[:idx] } From eb35a3457c4cc7fc726b633c06eb4dea03937d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 May 2021 17:00:25 +0200 Subject: [PATCH 17/27] Make sure docs URLs are linked in web docs --- pkg/cmd/actions/actions.go | 2 +- pkg/cmd/completion/completion.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index 33ba4b1a7..b618166ee 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -67,7 +67,7 @@ func actionsExplainer(cs *iostreams.ColorScheme) string { 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/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 From 42d2da812c83bc0a6a108f55b2476fd7fba5b070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 May 2021 17:01:33 +0200 Subject: [PATCH 18/27] Preserve list fomatting in web docs for `gh actions` --- pkg/cmd/actions/actions.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index b618166ee..356ca1581 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -48,20 +48,20 @@ 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 ' From 068ad31c14cbbb789c2dd0a251a09b66e7484850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 08:11:47 +0200 Subject: [PATCH 19/27] Add support for new Ubuntu, Kali linux (#3645) Co-authored-by: vilmibm --- .github/workflows/releases.yml | 2 +- script/distributions | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 4f593f36a..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 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 From 51f7cbdfde0a533b99db23f2e3d2f607ea693d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 09:58:21 +0200 Subject: [PATCH 20/27] :nail_care: cleanup and tests for PR finder --- api/queries_pr.go | 6 - pkg/cmd/pr/close/close.go | 2 +- pkg/cmd/pr/close/close_test.go | 2 +- pkg/cmd/pr/merge/merge_test.go | 10 +- pkg/cmd/pr/shared/finder.go | 62 ++++--- pkg/cmd/pr/shared/finder_test.go | 282 +++++++++++++++++++++++++++++-- 6 files changed, 317 insertions(+), 47 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index b5441a359..dfeb8608d 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -118,12 +118,6 @@ type PullRequestCommitCommit struct { } } -func (pr *PullRequest) StubCommit(oid string) { - pr.Commits.Nodes = append(pr.Commits.Nodes, PullRequestCommit{ - Commit: PullRequestCommitCommit{Oid: oid}, - }) -} - type PullRequestFile struct { Path string `json:"path"` Additions int `json:"additions"` diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 041d48edc..2c9442ecb 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -122,7 +122,7 @@ func closeRun(opts *CloseOptions) error { } if pr.IsCrossRepository { - fmt.Fprintf(opts.IO.ErrOut, "%s Avoiding deleting the remote branch of a pull request from fork\n", cs.WarningIcon()) + 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 } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 4024398dd..4a02dc13d 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -191,7 +191,7 @@ func TestPrClose_deleteBranch_crossRepo(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Closed pull request #96 (The title of the PR) - ! Avoiding deleting the remote branch of a pull request from fork + ! Skipped deleting the remote branch of a pull request from fork ✓ Deleted branch blueberries `), output.Stderr()) } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index a91b9740d..4abb88aca 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -203,6 +203,12 @@ func baseRepo(owner, repo, branch string) ghrepo.Interface { }, "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) @@ -456,7 +462,7 @@ func Test_nonDivergingPullRequest(t *testing.T) { Title: "Blueberries are a good fruit", State: "OPEN", } - pr.StubCommit("COMMITSHA1") + stubCommit(pr, "COMMITSHA1") shared.RunCommandFinder( "", @@ -497,7 +503,7 @@ func Test_divergingPullRequestWarning(t *testing.T) { Title: "Blueberries are a good fruit", State: "OPEN", } - pr.StubCommit("COMMITSHA1") + stubCommit(pr, "COMMITSHA1") shared.RunCommandFinder( "", diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index c17885ab2..52b2a436a 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -28,11 +28,12 @@ type progressIndicator interface { } type finder struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - remotesFn func() (context.Remotes, error) - httpClient func() (*http.Client, error) - progress progressIndicator + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + remotesFn func() (context.Remotes, error) + httpClient func() (*http.Client, error) + branchConfig func(string) git.BranchConfig + progress progressIndicator repo ghrepo.Interface prNumber int @@ -47,11 +48,12 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } return &finder{ - baseRepoFn: factory.BaseRepo, - branchFn: factory.Branch, - remotesFn: factory.Remotes, - httpClient: factory.HttpClient, - progress: factory.IOStreams, + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + remotesFn: factory.Remotes, + httpClient: factory.HttpClient, + progress: factory.IOStreams, + branchConfig: git.ReadBranchConfig, } } @@ -79,7 +81,10 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return nil, nil, errors.New("Find error: no fields specified") } - _ = f.parseURL(opts.Selector) + if repo, prNumber, err := f.parseURL(opts.Selector); err == nil { + f.prNumber = prNumber + f.repo = repo + } if f.repo == nil { repo, err := f.baseRepoFn() @@ -90,8 +95,12 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err } if opts.Selector == "" { - if err := f.parseCurrentBranch(); err != nil { + 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 { @@ -129,44 +138,44 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) -func (f *finder) parseURL(prURL string) error { +func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) { if prURL == "" { - return fmt.Errorf("invalid URL: %q", prURL) + return nil, 0, fmt.Errorf("invalid URL: %q", prURL) } u, err := url.Parse(prURL) if err != nil { - return err + return nil, 0, err } if u.Scheme != "https" && u.Scheme != "http" { - return fmt.Errorf("invalid scheme: %s", u.Scheme) + return nil, 0, fmt.Errorf("invalid scheme: %s", u.Scheme) } m := pullURLRE.FindStringSubmatch(u.Path) if m == nil { - return fmt.Errorf("not a pull request URL: %s", prURL) + return nil, 0, fmt.Errorf("not a pull request URL: %s", prURL) } - f.repo = ghrepo.NewWithHost(m[1], m[2], u.Hostname()) - f.prNumber, _ = strconv.Atoi(m[3]) - return nil + 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() error { +func (f *finder) parseCurrentBranch() (string, int, error) { prHeadRef, err := f.branchFn() if err != nil { - return err + return "", 0, err } - branchConfig := git.ReadBranchConfig(prHeadRef) + branchConfig := f.branchConfig(prHeadRef) // the branch is configured to merge a special PR head ref if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - f.prNumber, _ = strconv.Atoi(m[1]) - return nil + prNumber, _ := strconv.Atoi(m[1]) + return "", prNumber, nil } var branchOwner string @@ -193,8 +202,7 @@ func (f *finder) parseCurrentBranch() error { } } - f.branchName = prHeadRef - return nil + return prHeadRef, 0, nil } func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) { diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 7149bd4f8..488e470ff 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -1,21 +1,26 @@ 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) - remotesFn func() (context.Remotes, error) - selector string - fields []string + 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 @@ -44,6 +49,25 @@ func TestFind(t *testing.T) { 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{ @@ -93,6 +117,233 @@ func TestFind(t *testing.T) { 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) { @@ -106,19 +357,30 @@ func TestFind(t *testing.T) { httpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - baseRepoFn: tt.args.baseRepoFn, - branchFn: tt.args.branchFn, - remotesFn: tt.args.remotesFn, + 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, + 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) From e758f30073002dcb06fa4b3bb10e69706b5982be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 16:59:03 +0200 Subject: [PATCH 21/27] Fix preloading of pr reviews, checks, and issue/pr comments --- api/export_pr.go | 25 ++- api/export_pr_test.go | 2 +- api/pull_request_test.go | 2 +- api/queries_comments.go | 83 +------- api/queries_pr.go | 60 ++++-- api/queries_pr_review.go | 43 +---- api/query_builder.go | 58 ++++-- .../issueView_previewFullComments.json | 4 +- pkg/cmd/issue/view/http.go | 50 +++++ pkg/cmd/issue/view/view.go | 42 +++-- pkg/cmd/pr/checks/checks.go | 6 +- pkg/cmd/pr/checks/checks_test.go | 4 +- pkg/cmd/pr/checks/fixtures/allPassing.json | 2 +- pkg/cmd/pr/checks/fixtures/someFailing.json | 2 +- pkg/cmd/pr/checks/fixtures/somePending.json | 2 +- pkg/cmd/pr/checks/fixtures/withStatuses.json | 2 +- pkg/cmd/pr/merge/merge.go | 2 +- pkg/cmd/pr/merge/merge_test.go | 2 +- pkg/cmd/pr/shared/finder.go | 177 +++++++++++++++++- .../pr/status/fixtures/prStatusChecks.json | 6 +- pkg/cmd/pr/view/view.go | 5 +- 21 files changed, 383 insertions(+), 196 deletions(-) create mode 100644 pkg/cmd/issue/view/http.go diff --git a/api/export_pr.go b/api/export_pr.go index 9a0e10702..96411aead 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -49,11 +49,34 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { data[f] = nil } 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": diff --git a/api/export_pr_test.go b/api/export_pr_test.go index 4e02f9f09..9243e6a8d 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -129,7 +129,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/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 f02322c22..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 diff --git a/api/queries_pr.go b/api/queries_pr.go index dfeb8608d..eefbcdd49 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -75,6 +75,33 @@ type PullRequest struct { TotalCount int Nodes []PullRequestCommit } + StatusCheckRollup struct { + Nodes []struct { + Commit struct { + StatusCheckRollup struct { + Contexts struct { + Nodes []struct { + TypeName string `json:"__typename"` + Name string `json:"name"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + DetailsURL string `json:"detailsUrl"` + TargetURL string `json:"targetUrl,omitempty"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + } + } + } + } + Assignees Assignees Labels Labels ProjectCards ProjectCards @@ -89,6 +116,7 @@ type PRRepository struct { Name string } +// Commit loads just the commit SHA and nothing else type Commit struct { OID string `json:"oid"` } @@ -97,25 +125,20 @@ type PullRequestCommit struct { Commit PullRequestCommitCommit } -// PullRequestCommitCommit is like "Commit" but with StatusCheckRollup +// PullRequestCommitCommit contains full information about a commit type PullRequestCommitCommit struct { - Oid string - StatusCheckRollup struct { - Contexts struct { - Nodes []struct { - TypeName string `json:"__typename"` - Name string `json:"name"` - Context string `json:"context,omitempty"` - State string `json:"state,omitempty"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - StartedAt time.Time `json:"startedAt"` - CompletedAt time.Time `json:"completedAt"` - DetailsURL string `json:"detailsUrl"` - TargetURL string `json:"targetUrl,omitempty"` - } + 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 { @@ -132,7 +155,6 @@ type ReviewRequests struct { Name string `json:"name"` } } - TotalCount int } func (r ReviewRequests) Logins() []string { @@ -189,10 +211,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 == "" { 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/query_builder.go b/api/query_builder.go index 25862e179..a97d5eee9 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", @@ -178,8 +206,12 @@ 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) } 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 6888cc791..c24733c13 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,21 +106,9 @@ 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", err) } defer opts.IO.StopPager() @@ -131,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) diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index e97d1e46f..b21631820 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -92,11 +92,11 @@ func checksRun(opts *ChecksOptions) error { return opts.Browser.Browse(openURL) } - if len(pr.Commits.Nodes) == 0 { + if len(pr.StatusCheckRollup.Nodes) == 0 { return fmt.Errorf("no commit found on the pull request") } - rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes + 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) } @@ -118,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 6cedd6d32..0a189bd4d 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -83,7 +83,7 @@ func Test_checksRun(t *testing.T) { }, { name: "no checks", - prJSON: `{ "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, + prJSON: `{ "number": 123, "statusCheckRollup": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, wantOut: "", wantErr: "no checks reported on the 'master' branch", }, @@ -114,7 +114,7 @@ func Test_checksRun(t *testing.T) { { name: "no checks", nontty: true, - prJSON: `{ "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, + prJSON: `{ "number": 123, "statusCheckRollup": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`, wantOut: "", wantErr: "no checks reported on the 'master' branch", }, diff --git a/pkg/cmd/pr/checks/fixtures/allPassing.json b/pkg/cmd/pr/checks/fixtures/allPassing.json index 5116b5916..8d1f33510 100644 --- a/pkg/cmd/pr/checks/fixtures/allPassing.json +++ b/pkg/cmd/pr/checks/fixtures/allPassing.json @@ -1,6 +1,6 @@ { "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { diff --git a/pkg/cmd/pr/checks/fixtures/someFailing.json b/pkg/cmd/pr/checks/fixtures/someFailing.json index 887e22ab3..f407cd4b5 100644 --- a/pkg/cmd/pr/checks/fixtures/someFailing.json +++ b/pkg/cmd/pr/checks/fixtures/someFailing.json @@ -1,6 +1,6 @@ { "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { diff --git a/pkg/cmd/pr/checks/fixtures/somePending.json b/pkg/cmd/pr/checks/fixtures/somePending.json index 5214a7930..2d558f39e 100644 --- a/pkg/cmd/pr/checks/fixtures/somePending.json +++ b/pkg/cmd/pr/checks/fixtures/somePending.json @@ -1,6 +1,6 @@ { "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { diff --git a/pkg/cmd/pr/checks/fixtures/withStatuses.json b/pkg/cmd/pr/checks/fixtures/withStatuses.json index 2b4a808c7..ddc7374ba 100644 --- a/pkg/cmd/pr/checks/fixtures/withStatuses.json +++ b/pkg/cmd/pr/checks/fixtures/withStatuses.json @@ -1,6 +1,6 @@ { "number": 123, - "commits": { + "statusCheckRollup": { "nodes": [ { "commit": { diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 298d59b7f..f25732a7b 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -184,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 4abb88aca..abd0d9f55 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -205,7 +205,7 @@ func baseRepo(owner, repo, branch string) ghrepo.Interface { func stubCommit(pr *api.PullRequest, oid string) { pr.Commits.Nodes = append(pr.Commits.Nodes, api.PullRequestCommit{ - Commit: api.PullRequestCommitCommit{Oid: oid}, + Commit: api.PullRequestCommitCommit{OID: oid}, }) } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 52b2a436a..1ad253be2 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -1,6 +1,7 @@ package shared import ( + "context" "errors" "fmt" "net/http" @@ -11,11 +12,15 @@ import ( "strings" "github.com/cli/cli/api" - "github.com/cli/cli/context" + 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 { @@ -30,7 +35,7 @@ type progressIndicator interface { type finder struct { baseRepoFn func() (ghrepo.Interface, error) branchFn func() (string, error) - remotesFn func() (context.Remotes, error) + remotesFn func() (remotes.Remotes, error) httpClient func() (*http.Client, error) branchConfig func(string) git.BranchConfig progress progressIndicator @@ -120,20 +125,43 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err 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 len(opts.Fields) == 1 && opts.Fields[0] == "number" { + 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, opts.Fields) + 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 } - pr, err := findForBranch(httpClient, f.repo, opts.BaseBranch, f.branchName, opts.States, opts.Fields) + 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) + }) + } - // TODO: preload view: api.ReviewsForPullRequest, api.CommentsForPullRequest - // TODO: preload checks: get all checks - return pr, f.repo, err + return pr, f.repo, g.Wait() } var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) @@ -291,6 +319,139 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h 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 } 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/view/view.go b/pkg/cmd/pr/view/view.go index fd924387c..951ce7953 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -78,11 +78,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman var defaultFields = []string{ "url", "number", "title", "state", "body", "author", - "isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", + "isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount", "baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository", "reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone", - "comments", // TODO: fetch only 1 last comment unless `opts.Comments` was set - "reactionGroups", + "comments", "reactionGroups", } func viewRun(opts *ViewOptions) error { From 42155c7d2de352a667c99a64c9091c4b177eee38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 18:19:28 +0200 Subject: [PATCH 22/27] Export more IDs in issue/pr JSON payload --- api/export_pr.go | 14 +------------- api/export_pr_test.go | 10 ++++++++-- api/queries_issue.go | 22 +++++++++++++++------- api/queries_pr.go | 7 ++++--- api/query_builder.go | 16 ++++++++-------- api/query_builder_test.go | 2 +- pkg/cmd/issue/edit/edit.go | 4 +++- pkg/cmd/issue/view/view.go | 8 ++++++-- pkg/cmd/pr/checkout/checkout_test.go | 2 +- pkg/cmd/pr/close/close_test.go | 2 +- pkg/cmd/pr/edit/edit.go | 4 +++- pkg/cmd/pr/view/view.go | 8 ++++++-- 12 files changed, 57 insertions(+), 42 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 96411aead..4bd0aabc7 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -11,12 +11,6 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} { for _, f := range fields { switch f { - case "milestone": - if issue.Milestone.Title != "" { - data[f] = map[string]string{"title": issue.Milestone.Title} - } else { - data[f] = nil - } case "comments": data[f] = issue.Comments.Nodes case "assignees": @@ -41,13 +35,7 @@ func (pr *PullRequest) ExportData(fields []string) *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] = map[string]string{"title": pr.Milestone.Title} - } else { - data[f] = nil - } + data[f] = pr.HeadRepository case "statusCheckRollup": if n := pr.StatusCheckRollup.Nodes; len(n) > 0 { data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes diff --git a/api/export_pr_test.go b/api/export_pr_test.go index 9243e6a8d..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 } @@ -119,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 } diff --git a/api/queries_issue.go b/api/queries_issue.go index 18f2c83c7..1c4f122ec 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 } @@ -102,10 +98,14 @@ type IssuesDisabledError struct { } type Owner struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` Login string `json:"login"` } type Author struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` Login string `json:"login"` } @@ -273,13 +273,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 } @@ -295,7 +300,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 eefbcdd49..8a1d0e421 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -58,7 +58,7 @@ type PullRequest struct { Author Author MergedBy *Author HeadRepositoryOwner Owner - HeadRepository PRRepository + HeadRepository *PRRepository IsCrossRepository bool IsDraft bool MaintainerCanModify bool @@ -105,7 +105,7 @@ type PullRequest struct { Assignees Assignees Labels Labels ProjectCards ProjectCards - Milestone Milestone + Milestone *Milestone Comments Comments ReactionGroups ReactionGroups Reviews PullRequestReviews @@ -113,7 +113,8 @@ type PullRequest struct { } type PRRepository struct { - Name string + ID string `json:"id"` + Name string `json:"name"` } // Commit loads just the commit SHA and nothing else diff --git a/api/query_builder.go b/api/query_builder.go index a97d5eee9..04d681d60 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -21,7 +21,7 @@ func shortenQuery(q string) string { var issueComments = shortenQuery(` comments(first: 100) { nodes { - author{login}, + author{login,...on User{id,name}}, authorAssociation, body, createdAt, @@ -177,21 +177,21 @@ func PullRequestGraphQL(fields []string) string { for _, field := range fields { switch field { case "author": - q = append(q, `author{login}`) + q = append(q, `author{login,...on User{id,name}}`) case "mergedBy": - q = append(q, `mergedBy{login}`) + q = append(q, `mergedBy{login,,...on User{id,name}}`) 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": diff --git a/api/query_builder_test.go b/api/query_builder_test.go index e8d48a10e..3c510d6da 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,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}", }, { name: "compressed query", 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/view/view.go b/pkg/cmd/issue/view/view.go index c24733c13..b6b42ff9f 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -155,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 @@ -196,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_test.go b/pkg/cmd/pr/checkout/checkout_test.go index 583602628..932db4778 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -53,7 +53,7 @@ func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) { Number: 123, HeadRefName: headRefName, HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()}, - HeadRepository: api.PRRepository{Name: headRepo.RepoName()}, + HeadRepository: &api.PRRepository{Name: headRepo.RepoName()}, IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo), MaintainerCanModify: false, } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 4a02dc13d..c94fe83f3 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -53,7 +53,7 @@ func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) { State: "OPEN", HeadRefName: headRefName, HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()}, - HeadRepository: api.PRRepository{Name: headRepo.RepoName()}, + HeadRepository: &api.PRRepository{Name: headRepo.RepoName()}, IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo), } } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index ea1b73562..13481bcba 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -167,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) diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 951ce7953..91087d74b 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -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) } From 1440fd81a1e76fa6a32b1c5cc6e97affbed420e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 18:35:34 +0200 Subject: [PATCH 23/27] Fix broken GraphQL queries due to editing Author struct --- api/queries_issue.go | 5 +++-- api/query_builder.go | 8 ++++---- api/query_builder_test.go | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 1c4f122ec..c67ad3bcc 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -104,8 +104,9 @@ type Owner struct { } type Author struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` + // adding these breaks generated GraphQL requests + //ID string `json:"id,omitempty"` + //Name string `json:"name,omitempty"` Login string `json:"login"` } diff --git a/api/query_builder.go b/api/query_builder.go index 04d681d60..3bdbb8c9b 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -21,7 +21,7 @@ func shortenQuery(q string) string { var issueComments = shortenQuery(` comments(first: 100) { nodes { - author{login,...on User{id,name}}, + author{login}, authorAssociation, body, createdAt, @@ -177,11 +177,11 @@ func PullRequestGraphQL(fields []string) string { for _, field := range fields { switch field { case "author": - q = append(q, `author{login,...on User{id,name}}`) + q = append(q, `author{login}`) case "mergedBy": - q = append(q, `mergedBy{login,,...on User{id,name}}`) + q = append(q, `mergedBy{login}`) case "headRepositoryOwner": - q = append(q, `headRepositoryOwner{id,login,,...on User{name}}`) + q = append(q, `headRepositoryOwner{id,login,...on User{name}}`) case "headRepository": q = append(q, `headRepository{id,name}`) case "assignees": diff --git a/api/query_builder_test.go b/api/query_builder_test.go index 3c510d6da..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,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}", + want: "author{login},assignees(first:100){nodes{id,login,name},totalCount}", }, { name: "compressed query", From 4425365004faf125a33de404fbe7593cd206625e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 19:40:28 +0200 Subject: [PATCH 24/27] Add `release view --json` support --- pkg/cmd/issue/view/view.go | 2 +- pkg/cmd/release/create/create.go | 2 +- pkg/cmd/release/shared/fetch.go | 104 +++++++++++++++++++++++++++---- pkg/cmd/release/view/view.go | 24 +++++-- 4 files changed, 111 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b6b42ff9f..78be7df09 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -108,7 +108,7 @@ func viewRun(opts *ViewOptions) error { opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { - fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v", err) + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } defer opts.IO.StopPager() 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..9dad74f0a 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. 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) } From c667a0bc49598ea6de09f5127ae18a36f29b559e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 18 May 2021 19:44:29 +0200 Subject: [PATCH 25/27] Fix fetching draft releases from GitHub Actions When using GITHUB_TOKEN in Actions, the permissions on a repository are null and therefore we can't check whether the viewer has push access or not. The solution is to unconditionally check for draft releases instead of trying to be smart about it. Draft releases are going to be on top, so we don't have to paginate through all releases in a repository. --- pkg/cmd/release/shared/fetch.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 9dad74f0a..a964f9068 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -133,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 { @@ -230,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") From 79896ed513736b987a47f30e7a193e8132d5cb07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 19 May 2021 13:13:11 +0200 Subject: [PATCH 26/27] Fix `pr checkout` for cross-repository pull requests --- pkg/cmd/pr/checkout/checkout.go | 2 +- pkg/cmd/pr/checkout/checkout_test.go | 9 ++++--- pkg/cmd/pr/shared/finder.go | 36 +++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 84b16709f..0647aebb2 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -72,7 +72,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr func checkoutRun(opts *CheckoutOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, - Fields: []string{"number", "headRefName", "headRepository", "headRepositoryOwner"}, + Fields: []string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}, } pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index 932db4778..48150ff84 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -110,7 +110,8 @@ func TestPRCheckout_sameRepo(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.RunCommandFinder("123", pr, baseRepo) + finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -164,7 +165,8 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.RunCommandFinder("123", pr, baseRepo) + finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -186,7 +188,8 @@ func TestPRCheckout_differentRepo(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.RunCommandFinder("123", pr, baseRepo) + finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"}) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 1ad253be2..7b90e146c 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -65,8 +65,10 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { 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) { - runCommandFinder = NewMockFinder(selector, pr, repo) +func RunCommandFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder { + finder := NewMockFinder(selector, pr, repo) + runCommandFinder = finder + return finder } type FindOptions struct { @@ -460,7 +462,7 @@ func (err *NotFoundError) Unwrap() error { return err.error } -func NewMockFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) PRFinder { +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")} @@ -476,6 +478,7 @@ func NewMockFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) type mockFinder struct { called bool expectSelector string + expectFields []string pr *api.PullRequest repo ghrepo.Interface err error @@ -488,6 +491,9 @@ func (m *mockFinder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, 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") } @@ -500,3 +506,27 @@ func (m *mockFinder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, 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 +} From e0e25c82ff7b50d5587ebdb42efdf3822e759e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 19 May 2021 16:29:44 +0200 Subject: [PATCH 27/27] Fix creating Windows directory for gh config --- internal/config/config_file.go | 12 +++--------- internal/config/config_file_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) 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)) + } +}